diff --git a/.env.example b/.env.example index 80132b9..2c4f09b 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,15 @@ -# Database +# Database (Neon in production, plain Postgres locally) +# +# In production these are two DIFFERENT Neon endpoints, distinguished by +# hostname rather than port: the pooled one carries a `-pooler` suffix. +# DATABASE_URL=postgresql://user:pass@ep-xxx-pooler.region.aws.neon.tech/overlap?sslmode=require +# DIRECT_URL=postgresql://user:pass@ep-xxx.region.aws.neon.tech/overlap?sslmode=require +# Setting both to the same endpoint works until it does not: migrations need +# the direct one, and the app needs the pooled one. +# +# Locally, a single Postgres accepts both. DATABASE_URL=postgresql://postgres:postgres@localhost:5432/overlap - -# Redis -REDIS_URL=redis://localhost:6379 +DIRECT_URL=postgresql://postgres:postgres@localhost:5432/overlap # GitHub App GITHUB_APP_ID= @@ -14,15 +21,14 @@ GITHUB_CLIENT_SECRET= # App SESSION_SECRET= APP_URL=http://localhost:3000 -API_URL=http://localhost:3001 GITHUB_APP_SLUG=overlap-connector +CRON_SECRET= # Web Push (VAPID) VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= -VAPID_SUBJECT=https://your-app-url.up.railway.app +VAPID_SUBJECT=https://your-production-domain.example # Frontend (prefixed with VITE_ for client-side access) -VITE_API_URL=http://localhost:3001 VITE_GITHUB_APP_SLUG=overlap-connector VITE_VAPID_PUBLIC_KEY= diff --git a/.gitignore b/.gitignore index 12702ef..5482f91 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules dist .next .output +.vercel .turbo *.tsbuildinfo @@ -35,3 +36,6 @@ coverage # Database drizzle/*.sql prod_dump.sql + +# Internal ship-run review transcripts +.ship-fix-report.md diff --git a/.ship/config.md b/.ship/config.md new file mode 100644 index 0000000..f5a7768 --- /dev/null +++ b/.ship/config.md @@ -0,0 +1,6 @@ +# ship pipeline profile +verify: npx turbo typecheck lint test --force && pnpm test:integration && pnpm --filter @overlap/web build # asked +base: main +release: none +post-merge: none +pr-hook: none diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile deleted file mode 100644 index 0988dea..0000000 --- a/apps/api/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM node:20-slim - -# Install pnpm -RUN npm install -g pnpm - -WORKDIR /app - -# Copy workspace config files -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ - -# Copy packages and app -COPY packages/ ./packages/ -COPY apps/api/ ./apps/api/ - -# Install dependencies -RUN pnpm install --frozen-lockfile - -# Build API (tsup bundles workspace dependencies) -RUN pnpm --filter @overlap/api build - -# Start the API -CMD ["node", "apps/api/dist/index.js"] diff --git a/apps/api/package.json b/apps/api/package.json deleted file mode 100644 index 56178b1..0000000 --- a/apps/api/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@overlap/api", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx watch --env-file=../../.env src/index.ts", - "build": "tsup", - "start": "node dist/index.js", - "typecheck": "tsc --noEmit", - "lint": "eslint src/" - }, - "dependencies": { - "@fastify/cookie": "^11.0.0", - "@fastify/cors": "^10.0.0", - "@fastify/rate-limit": "^10.0.0", - "@overlap/db": "workspace:*", - "@overlap/github": "workspace:*", - "@overlap/shared": "workspace:*", - "bullmq": "^5.25.0", - "drizzle-orm": "^0.38.0", - "fastify": "^5.0.0", - "fastify-plugin": "^5.1.0", - "ioredis": "^5.4.0" - }, - "devDependencies": { - "@types/node": "^20.11.0", - "pino-pretty": "^13.0.0", - "tsup": "^8.0.0", - "tsx": "^4.19.0", - "typescript": "^5.7.0" - } -} diff --git a/apps/api/railway.json b/apps/api/railway.json deleted file mode 100644 index ef80472..0000000 --- a/apps/api/railway.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "apps/api/Dockerfile" - }, - "deploy": { - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 3 - } -} diff --git a/apps/api/railway.toml b/apps/api/railway.toml deleted file mode 100644 index b8469a1..0000000 --- a/apps/api/railway.toml +++ /dev/null @@ -1,8 +0,0 @@ -[build] -builder = "nixpacks" -buildCommand = "cd ../.. && pnpm install && pnpm --filter @overlap/api build" - -[deploy] -startCommand = "node dist/index.js" -restartPolicyType = "ON_FAILURE" -restartPolicyMaxRetries = 3 diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts deleted file mode 100644 index ca747e7..0000000 --- a/apps/api/src/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -import Fastify from 'fastify' -import cors from '@fastify/cors' -import rateLimit from '@fastify/rate-limit' -import { authPlugin } from './plugins/auth.js' -import { webhooksRoute } from './routes/webhooks.js' -import { healthRoute } from './routes/health.js' -import { repositoriesRoute } from './routes/repositories.js' -import { authRoute } from './routes/auth.js' -import { pushRoute } from './routes/push.js' -import { setupQueues } from './queues/index.js' -import { setupScheduler } from './scheduler.js' - -const fastify = Fastify({ - logger: { - level: process.env.LOG_LEVEL || 'info', - transport: - process.env.NODE_ENV === 'development' - ? { - target: 'pino-pretty', - options: { - translateTime: 'HH:MM:ss Z', - ignore: 'pid,hostname', - }, - } - : undefined, - }, -}) - -async function start() { - try { - // Register plugins - await fastify.register(cors, { - origin: process.env.APP_URL || 'http://localhost:3000', - credentials: true, - }) - - await fastify.register(rateLimit, { - max: 100, - timeWindow: '1 minute', - }) - - // Register auth plugin (cookie parsing + session) - await fastify.register(authPlugin) - - // Setup BullMQ queues - const queues = setupQueues() - fastify.decorate('queues', queues) - - // Register routes - await fastify.register(healthRoute, { prefix: '/health' }) - await fastify.register(webhooksRoute, { prefix: '/webhooks' }) - await fastify.register(authRoute, { prefix: '/auth' }) - await fastify.register(repositoriesRoute, { prefix: '/api/repositories' }) - await fastify.register(pushRoute, { prefix: '/api/push' }) - - // Setup scheduled jobs - await setupScheduler() - - // Start server - const port = parseInt(process.env.PORT || '3001', 10) - const host = process.env.HOST || '0.0.0.0' - - await fastify.listen({ port, host }) - fastify.log.info(`Server listening on ${host}:${port}`) - } catch (err) { - fastify.log.error(err) - process.exit(1) - } -} - -// Graceful shutdown -const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'] -for (const signal of signals) { - process.on(signal, async () => { - fastify.log.info(`Received ${signal}, shutting down...`) - await fastify.close() - process.exit(0) - }) -} - -start() diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts deleted file mode 100644 index e139934..0000000 --- a/apps/api/src/plugins/auth.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' -import fp from 'fastify-plugin' -import cookie from '@fastify/cookie' -import { db, users } from '@overlap/db' -import { eq } from 'drizzle-orm' - -declare module 'fastify' { - interface FastifyRequest { - user: { - id: string - githubId: number - username: string - email: string | null - avatarUrl: string | null - } | null - } -} - -export const authPlugin = fp(async function authPlugin(fastify: FastifyInstance) { - const sessionSecret = process.env.SESSION_SECRET - if (!sessionSecret) { - throw new Error('SESSION_SECRET environment variable is required') - } - - await fastify.register(cookie, { - secret: sessionSecret, - hook: 'onRequest', - parseOptions: {}, - }) - - fastify.decorateRequest('user', null) - - fastify.addHook('onRequest', async (request: FastifyRequest) => { - const signed = request.cookies.session - if (!signed) return - - const unsigned = request.unsignCookie(signed) - if (!unsigned.valid || !unsigned.value) return - - let parsed: { userId: string } - try { - parsed = JSON.parse(unsigned.value) - } catch { - return - } - - if (!parsed.userId) return - - const user = await db.query.users.findFirst({ - where: eq(users.id, parsed.userId), - }) - - if (user) { - request.user = { - id: user.id, - githubId: user.githubId, - username: user.username, - email: user.email, - avatarUrl: user.avatarUrl, - } - } - }) -}) - -export async function requireAuth(request: FastifyRequest, reply: FastifyReply) { - if (!request.user) { - return reply.status(401).send({ error: 'Unauthorized' }) - } -} diff --git a/apps/api/src/queues/index.ts b/apps/api/src/queues/index.ts deleted file mode 100644 index a1f9506..0000000 --- a/apps/api/src/queues/index.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Queue } from 'bullmq' -import { Redis } from 'ioredis' -import { QUEUE_NAMES, type QueueName } from '@overlap/shared' -import type { - WebhookEventJob, - BranchSyncJob, - OverlapDetectionJob, - GitHubFeedbackJob, - MaintenanceJob, - PushNotificationJob, -} from '@overlap/shared' - -export type Queues = { - webhookEvents: Queue - branchSync: Queue - overlapDetection: Queue - githubFeedback: Queue - maintenance: Queue - pushNotification: Queue -} - -let connection: Redis | null = null - -function getConnection(): Redis { - if (!connection) { - const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' - connection = new Redis(redisUrl, { - maxRetriesPerRequest: null, - }) - } - return connection -} - -export function setupQueues(): Queues { - const conn = getConnection() - - const defaultJobOptions = { - removeOnComplete: { age: 3600, count: 1000 }, - removeOnFail: { age: 86400, count: 5000 }, - } - - return { - webhookEvents: new Queue(QUEUE_NAMES.WEBHOOK_EVENTS, { - connection: conn, - defaultJobOptions, - }), - branchSync: new Queue(QUEUE_NAMES.BRANCH_SYNC, { - connection: conn, - defaultJobOptions: { - ...defaultJobOptions, - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }, - }), - overlapDetection: new Queue(QUEUE_NAMES.OVERLAP_DETECTION, { - connection: conn, - defaultJobOptions, - }), - githubFeedback: new Queue(QUEUE_NAMES.GITHUB_FEEDBACK, { - connection: conn, - defaultJobOptions: { - ...defaultJobOptions, - attempts: 3, - backoff: { - type: 'exponential', - delay: 10000, - }, - }, - }), - maintenance: new Queue(QUEUE_NAMES.MAINTENANCE, { - connection: conn, - defaultJobOptions, - }), - pushNotification: new Queue(QUEUE_NAMES.PUSH_NOTIFICATION, { - connection: conn, - defaultJobOptions, - }), - } -} - -export async function addWebhookEventJob( - queues: Queues, - job: WebhookEventJob -): Promise { - await queues.webhookEvents.add('process', job, { - jobId: job.deliveryId, - }) -} - -export async function addBranchSyncJob( - queues: Queues, - job: BranchSyncJob -): Promise { - const jobId = `${job.repositoryId}:${job.branchName}:${job.sha}` - await queues.branchSync.add('sync', job, { jobId }) -} - -export async function addOverlapDetectionJob( - queues: Queues, - job: OverlapDetectionJob -): Promise { - const jobId = `${job.repositoryId}:${job.branchId}:${Date.now()}` - await queues.overlapDetection.add('detect', job, { jobId }) -} - -export async function addGitHubFeedbackJob( - queues: Queues, - job: GitHubFeedbackJob -): Promise { - const jobId = `${job.pullRequestId}:${job.overlapId}` - await queues.githubFeedback.add('feedback', job, { jobId }) -} - -export async function addMaintenanceJob( - queues: Queues, - job: MaintenanceJob -): Promise { - const jobId = job.repositoryId - ? `${job.type}:${job.repositoryId}` - : `${job.type}:global` - await queues.maintenance.add(job.type, job, { jobId }) -} - -// Type augmentation for Fastify -declare module 'fastify' { - interface FastifyInstance { - queues: Queues - } -} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts deleted file mode 100644 index 111ff99..0000000 --- a/apps/api/src/routes/auth.ts +++ /dev/null @@ -1,284 +0,0 @@ -import type { FastifyInstance } from 'fastify' -import { db, users, githubAppInstallations, userInstallations, repositories, repositorySettings } from '@overlap/db' -import { eq, and } from 'drizzle-orm' -import { githubOAuthCallbackSchema } from '@overlap/shared' -import { requireAuth } from '../plugins/auth.js' - -export async function authRoute(fastify: FastifyInstance) { - const clientId = process.env.GITHUB_CLIENT_ID - const clientSecret = process.env.GITHUB_CLIENT_SECRET - const apiUrl = process.env.API_URL || 'http://localhost:3001' - const appUrl = process.env.APP_URL || 'http://localhost:3000' - - if (!clientId || !clientSecret) { - fastify.log.warn('GITHUB_CLIENT_ID or GITHUB_CLIENT_SECRET not set — auth routes disabled') - return - } - - // Redirect to GitHub OAuth - fastify.get('/github', async (request, reply) => { - const state = crypto.randomUUID() - - reply.setCookie('oauth_state', state, { - signed: true, - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - path: '/', - maxAge: 600, // 10 minutes - }) - - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: `${appUrl}/auth/github/callback`, - scope: 'read:user user:email', - state, - }) - - return reply.redirect(`https://github.com/login/oauth/authorize?${params}`) - }) - - // GitHub OAuth callback - fastify.get<{ Querystring: { code?: string; state?: string; setup_action?: string } }>( - '/github/callback', - async (request, reply) => { - // If this callback came from a GitHub App installation flow (no state cookie), - // redirect through our own OAuth flow to establish CSRF protection. - // GitHub will auto-approve since the user already authorized. - const stateCookie = request.cookies.oauth_state - if (!stateCookie) { - return reply.redirect(`${appUrl}/auth/github`) - } - - const { code, state } = githubOAuthCallbackSchema.parse(request.query) - - // Verify state (CSRF protection) — always enforced - const unsigned = request.unsignCookie(stateCookie) - if (!unsigned.valid || unsigned.value !== state) { - return reply.status(400).send({ error: 'Invalid OAuth state' }) - } - - // Exchange code for access token - const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - client_id: clientId, - client_secret: clientSecret, - code, - }), - }) - - const tokenData = (await tokenResponse.json()) as { - access_token?: string - error?: string - } - - if (!tokenData.access_token) { - fastify.log.error({ tokenData }, 'Failed to exchange code for token') - return reply.status(400).send({ error: 'Failed to exchange code for token' }) - } - - // Fetch user profile - const userResponse = await fetch('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${tokenData.access_token}`, - Accept: 'application/vnd.github+json', - }, - }) - - const githubUser = (await userResponse.json()) as { - id: number - login: string - email: string | null - avatar_url: string - } - - // Upsert user - const [user] = await db - .insert(users) - .values({ - githubId: githubUser.id, - username: githubUser.login, - email: githubUser.email, - avatarUrl: githubUser.avatar_url, - }) - .onConflictDoUpdate({ - target: users.githubId, - set: { - username: githubUser.login, - email: githubUser.email, - avatarUrl: githubUser.avatar_url, - updatedAt: new Date(), - }, - }) - .returning() - - // Set session cookie - reply.setCookie( - 'session', - JSON.stringify({ userId: user.id }), - { - signed: true, - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - path: '/', - maxAge: 60 * 60 * 24 * 7, // 7 days - } - ) - - // Clear oauth_state cookie - reply.clearCookie('oauth_state', { path: '/' }) - - // Sync user's GitHub App installations into local DB - await syncUserInstallations(tokenData.access_token, user.id) - - // Check if user has any active installations - const userInsts = await db.query.userInstallations.findMany({ - where: eq(userInstallations.userId, user.id), - with: { installation: true }, - }) - const hasActive = userInsts.some(ui => ui.installation.status === 'active') - - if (!hasActive) { - return reply.redirect(`${appUrl}?setup=1`) - } - - return reply.redirect(appUrl) - } - ) - - // Get current user - fastify.get('/me', { preHandler: [requireAuth] }, async (request) => { - const userInsts = await db.query.userInstallations.findMany({ - where: eq(userInstallations.userId, request.user!.id), - with: { installation: true }, - }) - const hasActive = userInsts.some(ui => ui.installation.status === 'active') - - return { - user: request.user, - hasInstallations: hasActive, - } - }) - - // Logout - fastify.post('/logout', async (request, reply) => { - reply.clearCookie('session', { path: '/' }) - return { success: true } - }) -} - -async function syncUserInstallations(accessToken: string, userId: string) { - try { - const res = await fetch('https://api.github.com/user/installations', { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github+json', - }, - }) - - if (!res.ok) return - - const data = (await res.json()) as { - installations: Array<{ - id: number - account: { login: string; type: string } - }> - } - - for (const inst of data.installations) { - const [installation] = await db - .insert(githubAppInstallations) - .values({ - installationId: inst.id, - userId, - status: 'active', - }) - .onConflictDoUpdate({ - target: githubAppInstallations.installationId, - set: { - status: 'active', - updatedAt: new Date(), - }, - }) - .returning() - - // Link user to installation (many-to-many) - await db - .insert(userInstallations) - .values({ userId, installationId: installation.id }) - .onConflictDoNothing() - - // Sync repos for this installation - await syncInstallationRepos(accessToken, inst.id, installation.id) - } - } catch (err) { - console.error('Failed to sync installations:', err) - } -} - -async function syncInstallationRepos(accessToken: string, installationId: number, dbInstallationId: string) { - try { - const res = await fetch( - `https://api.github.com/user/installations/${installationId}/repositories`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github+json', - }, - } - ) - - if (!res.ok) return - - const data = (await res.json()) as { - repositories: Array<{ - id: number - name: string - full_name: string - private: boolean - default_branch: string - }> - } - - for (const repo of data.repositories) { - const [inserted] = await db - .insert(repositories) - .values({ - githubId: repo.id, - installationId: dbInstallationId, - name: repo.name, - fullName: repo.full_name, - defaultBranch: repo.default_branch, - isPrivate: repo.private, - isActive: true, - }) - .onConflictDoUpdate({ - target: repositories.githubId, - set: { - installationId: dbInstallationId, - name: repo.name, - fullName: repo.full_name, - defaultBranch: repo.default_branch, - isPrivate: repo.private, - isActive: true, - updatedAt: new Date(), - }, - }) - .returning() - - // Ensure default settings exist - await db - .insert(repositorySettings) - .values({ repositoryId: inserted.id }) - .onConflictDoNothing() - } - } catch (err) { - console.error(`Failed to sync repos for installation ${installationId}:`, err) - } -} diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts deleted file mode 100644 index 53cb2d3..0000000 --- a/apps/api/src/routes/health.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { FastifyInstance } from 'fastify' -import { db } from '@overlap/db' -import { sql } from 'drizzle-orm' - -export async function healthRoute(fastify: FastifyInstance) { - fastify.get('/', async (request, reply) => { - return { status: 'ok', timestamp: new Date().toISOString() } - }) - - fastify.get('/ready', async (request, reply) => { - const checks: Record = { - database: false, - redis: false, - } - - // Check database - try { - await db.execute(sql`SELECT 1`) - checks.database = true - } catch (err) { - fastify.log.error({ err }, 'Database health check failed') - } - - // Check Redis via BullMQ queue - try { - const client = await fastify.queues.webhookEvents.client - await client.ping() - checks.redis = true - } catch (err) { - fastify.log.error({ err }, 'Redis health check failed') - } - - const allHealthy = Object.values(checks).every((v) => v) - - return reply.status(allHealthy ? 200 : 503).send({ - status: allHealthy ? 'ready' : 'not ready', - checks, - timestamp: new Date().toISOString(), - }) - }) -} diff --git a/apps/api/src/routes/push.ts b/apps/api/src/routes/push.ts deleted file mode 100644 index 264117b..0000000 --- a/apps/api/src/routes/push.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { FastifyInstance } from 'fastify' -import { db, pushSubscriptions } from '@overlap/db' -import { eq, and } from 'drizzle-orm' -import { requireAuth } from '../plugins/auth.js' - -// Legitimate browser push service domains -const ALLOWED_PUSH_HOSTS = [ - 'fcm.googleapis.com', - 'updates.push.services.mozilla.com', - 'push.services.mozilla.com', - 'notify.windows.com', - 'web.push.apple.com', -] - -function isAllowedPushEndpoint(endpoint: string): boolean { - let url: URL - try { - url = new URL(endpoint) - } catch { - return false - } - - if (url.protocol !== 'https:') return false - - const hostname = url.hostname.toLowerCase() - return ALLOWED_PUSH_HOSTS.some( - (domain) => hostname === domain || hostname.endsWith('.' + domain) - ) -} - -export async function pushRoute(fastify: FastifyInstance) { - // All push routes require authentication - fastify.addHook('preHandler', requireAuth) - - // Subscribe to push notifications - fastify.post<{ - Body: { endpoint: string; keys: { p256dh: string; auth: string } } - }>('/subscribe', async (request, reply) => { - const { endpoint, keys } = request.body - - if (!isAllowedPushEndpoint(endpoint)) { - return reply.status(400).send({ error: 'Invalid push endpoint' }) - } - - await db - .insert(pushSubscriptions) - .values({ - userId: request.user!.id, - endpoint, - p256dh: keys.p256dh, - auth: keys.auth, - }) - .onConflictDoUpdate({ - target: [pushSubscriptions.userId, pushSubscriptions.endpoint], - set: { - p256dh: keys.p256dh, - auth: keys.auth, - }, - }) - - return { success: true } - }) - - // Unsubscribe from push notifications - fastify.delete<{ Body: { endpoint: string } }>('/unsubscribe', async (request) => { - const { endpoint } = request.body - - await db - .delete(pushSubscriptions) - .where( - and( - eq(pushSubscriptions.userId, request.user!.id), - eq(pushSubscriptions.endpoint, endpoint) - ) - ) - - return { success: true } - }) -} diff --git a/apps/api/src/routes/repositories.ts b/apps/api/src/routes/repositories.ts deleted file mode 100644 index f90c70b..0000000 --- a/apps/api/src/routes/repositories.ts +++ /dev/null @@ -1,328 +0,0 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' -import { db, repositories, branches, overlaps, repositorySettings, githubAppInstallations, userInstallations } from '@overlap/db' -import { eq, and, desc, count, sql, inArray } from 'drizzle-orm' -import { - repositoryIdParamSchema, - repositorySettingsUpdateSchema, - branchQuerySchema, - overlapQuerySchema, - overlapUpdateSchema, - diffQuerySchema, -} from '@overlap/shared' -import { getGitHubClient } from '@overlap/github' -import { requireAuth } from '../plugins/auth.js' - -export async function repositoriesRoute(fastify: FastifyInstance) { - // All repository routes require authentication - fastify.addHook('preHandler', requireAuth) - - // Helper to get user's installation IDs (via many-to-many join table) - async function getUserInstallationIds(userId: string): Promise { - const links = await db.query.userInstallations.findMany({ - where: eq(userInstallations.userId, userId), - with: { installation: true }, - }) - return links - .filter((l) => l.installation.status === 'active') - .map((l) => l.installationId) - } - - // Helper to verify the authenticated user has access to a repository - async function requireRepoAccess(request: FastifyRequest, reply: FastifyReply, repoId: string) { - const installationIds = await getUserInstallationIds(request.user!.id) - const repo = await db.query.repositories.findFirst({ - where: and( - eq(repositories.id, repoId), - installationIds.length > 0 - ? inArray(repositories.installationId, installationIds) - : sql`false` - ), - }) - if (!repo) { - reply.status(404).send({ error: 'Repository not found' }) - return null - } - return repo - } - - // List repositories (scoped to user's installations) - fastify.get('/', async (request, reply) => { - const installationIds = await getUserInstallationIds(request.user!.id) - - if (installationIds.length === 0) { - return [] - } - - const repos = await db.query.repositories.findMany({ - where: and( - eq(repositories.isActive, true), - inArray(repositories.installationId, installationIds) - ), - with: { - settings: true, - }, - orderBy: desc(repositories.updatedAt), - }) - - // Get summary stats for each repo - const results = await Promise.all( - repos.map(async (repo) => { - const [branchCount] = await db - .select({ count: count() }) - .from(branches) - .where(and(eq(branches.repositoryId, repo.id), eq(branches.isDefault, false))) - - const [overlapCount] = await db - .select({ count: count() }) - .from(overlaps) - .where(and(eq(overlaps.repositoryId, repo.id), eq(overlaps.status, 'active'))) - - return { - id: repo.id, - name: repo.name, - fullName: repo.fullName, - defaultBranch: repo.defaultBranch, - isPrivate: repo.isPrivate, - activeBranches: branchCount?.count ?? 0, - activeOverlaps: overlapCount?.count ?? 0, - lastSyncedAt: repo.lastSyncedAt, - settings: repo.settings, - } - }) - ) - - return results - }) - - // Get repository by ID (scoped to user's installations) - fastify.get<{ Params: { id: string } }>('/:id', async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - // Re-query with relations for the detail view - const repoWithRelations = await db.query.repositories.findFirst({ - where: eq(repositories.id, id), - with: { - settings: true, - installation: true, - }, - }) - - return repoWithRelations - }) - - // Update repository settings - fastify.patch<{ Params: { id: string }; Body: unknown }>( - '/:id/settings', - async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const updates = repositorySettingsUpdateSchema.parse(request.body) - - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - const [updated] = await db - .update(repositorySettings) - .set({ - ...updates, - updatedAt: new Date(), - }) - .where(eq(repositorySettings.repositoryId, id)) - .returning() - - return updated - } - ) - - // List branches for a repository - fastify.get<{ Params: { id: string }; Querystring: unknown }>( - '/:id/branches', - async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const { includeDefault, includeStale, page, limit } = branchQuerySchema.parse( - request.query - ) - - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - const conditions = [eq(branches.repositoryId, id)] - - if (!includeDefault) { - conditions.push(eq(branches.isDefault, false)) - } - - if (!includeStale) { - const staleDate = new Date() - staleDate.setDate(staleDate.getDate() - 14) - conditions.push(sql`${branches.lastSeenAt} > ${staleDate.toISOString()}`) - } - - const branchList = await db.query.branches.findMany({ - where: and(...conditions), - orderBy: desc(branches.lastSeenAt), - limit, - offset: (page - 1) * limit, - }) - - return branchList - } - ) - - // List overlaps for a repository - fastify.get<{ Params: { id: string }; Querystring: unknown }>( - '/:id/overlaps', - async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const { status, severity, branchId, page, limit } = overlapQuerySchema.parse( - request.query - ) - - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - const conditions = [eq(overlaps.repositoryId, id)] - - if (status) { - conditions.push(eq(overlaps.status, status)) - } - - if (severity) { - conditions.push(eq(overlaps.severity, severity)) - } - - if (branchId) { - conditions.push( - sql`(${overlaps.sourceBranchId} = ${branchId} OR ${overlaps.targetBranchId} = ${branchId})` - ) - } - - const overlapList = await db.query.overlaps.findMany({ - where: and(...conditions), - with: { - sourceBranch: true, - targetBranch: true, - files: true, - }, - orderBy: desc(overlaps.detectedAt), - limit, - offset: (page - 1) * limit, - }) - - return overlapList - } - ) - - // Get all file diffs between two branches - fastify.get<{ Params: { id: string }; Querystring: unknown }>( - '/:id/diffs', - async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const { base, head } = diffQuerySchema.parse(request.query) - - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - const repoWithInstallation = await db.query.repositories.findFirst({ - where: eq(repositories.id, id), - with: { installation: true }, - }) - - if (!repoWithInstallation?.installation) { - return reply.status(500).send({ error: 'Installation not found' }) - } - - const [owner, name] = repoWithInstallation.fullName.split('/') - const github = getGitHubClient() - - try { - const diffs = await github.getCompareDiffs( - repoWithInstallation.installation.installationId, - owner, - name, - base, - head - ) - - return { files: diffs } - } catch (error: unknown) { - const err = error as { status?: number; message?: string } - if (err.status === 404) { - return reply.status(404).send({ error: 'Branch no longer exists' }) - } - throw error - } - } - ) - - // Update overlap status (resolve/ignore) - fastify.patch<{ Params: { id: string; overlapId: string }; Body: unknown }>( - '/:id/overlaps/:overlapId', - async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const overlapId = request.params.overlapId - const { status } = overlapUpdateSchema.parse(request.body) - - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - const overlap = await db.query.overlaps.findFirst({ - where: and(eq(overlaps.id, overlapId), eq(overlaps.repositoryId, id)), - }) - - if (!overlap) { - return reply.status(404).send({ error: 'Overlap not found' }) - } - - const [updated] = await db - .update(overlaps) - .set({ - status, - resolvedAt: status === 'resolved' ? new Date() : null, - updatedAt: new Date(), - }) - .where(eq(overlaps.id, overlapId)) - .returning() - - return updated - } - ) - - // DEV ONLY: Test push notification for an existing overlap - if (process.env.NODE_ENV !== 'production') { - fastify.post<{ Params: { id: string; overlapId: string } }>( - '/:id/overlaps/:overlapId/test-notify', - async (request, reply) => { - const { id } = repositoryIdParamSchema.parse(request.params) - const overlapId = request.params.overlapId - - const repo = await requireRepoAccess(request, reply, id) - if (!repo) return - - const overlap = await db.query.overlaps.findFirst({ - where: and(eq(overlaps.id, overlapId), eq(overlaps.repositoryId, id)), - with: { sourceBranch: true, targetBranch: true }, - }) - - if (!overlap) { - return reply.status(404).send({ error: 'Overlap not found' }) - } - - await fastify.queues.pushNotification.add( - 'notify', - { - repositoryId: id, - overlapId, - targetBranchId: overlap.targetBranchId, - }, - { - jobId: `test-${overlapId}-${Date.now()}`, - } - ) - - return { success: true, message: 'Test notification queued' } - } - ) - } -} diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts deleted file mode 100644 index b464290..0000000 --- a/apps/api/src/routes/webhooks.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' -import { verifyWebhookSignature, extractBranchFromRef, isBranchDeletion } from '@overlap/github' -import { db, webhookEvents, repositories, branches, branchFiles } from '@overlap/db' -import { eq, and } from 'drizzle-orm' -import { GITHUB_EVENTS, PR_ACTIONS } from '@overlap/shared' -import { addWebhookEventJob } from '../queues/index.js' - -interface WebhookHeaders { - 'x-github-event': string - 'x-github-delivery': string - 'x-hub-signature-256': string -} - -export async function webhooksRoute(fastify: FastifyInstance) { - // Disable body parsing to get raw body for signature verification - fastify.addContentTypeParser( - 'application/json', - { parseAs: 'string' }, - (req, body, done) => { - done(null, body) - } - ) - - fastify.post<{ - Headers: WebhookHeaders - Body: string - }>('/github', async (request, reply) => { - const signature = request.headers['x-hub-signature-256'] - const event = request.headers['x-github-event'] - const deliveryId = request.headers['x-github-delivery'] - - // Verify webhook secret - const secret = process.env.GITHUB_WEBHOOK_SECRET - if (!secret) { - fastify.log.error('GITHUB_WEBHOOK_SECRET not configured') - return reply.status(500).send({ error: 'Server configuration error' }) - } - - const verification = verifyWebhookSignature(request.body, signature, secret) - if (!verification.valid) { - fastify.log.warn(`Invalid webhook signature: ${verification.error}`) - return reply.status(401).send({ error: 'Invalid signature' }) - } - - // Parse payload - let payload: Record - try { - payload = JSON.parse(request.body) - } catch { - return reply.status(400).send({ error: 'Invalid JSON payload' }) - } - - // Get repository ID if available - let repositoryId: string | null = null - const repoData = payload.repository as { id?: number } | undefined - if (repoData?.id) { - const repo = await db.query.repositories.findFirst({ - where: eq(repositories.githubId, repoData.id), - }) - repositoryId = repo?.id ?? null - } - - // Store webhook event - await db.insert(webhookEvents).values({ - eventType: event, - deliveryId, - repositoryId, - payload, - }) - - // Handle specific events - switch (event) { - case GITHUB_EVENTS.PUSH: - await handlePushEvent(fastify, payload, deliveryId) - break - - case GITHUB_EVENTS.PULL_REQUEST: - await handlePullRequestEvent(fastify, payload, deliveryId) - break - - case GITHUB_EVENTS.INSTALLATION: - case GITHUB_EVENTS.INSTALLATION_REPOSITORIES: - await handleInstallationEvent(fastify, payload, deliveryId) - break - - default: - fastify.log.info(`Ignoring unhandled event: ${event}`) - } - - // Add to queue for processing - await addWebhookEventJob(fastify.queues, { - eventType: event, - deliveryId, - payload, - }) - - return reply.status(200).send({ received: true }) - }) -} - -async function handlePushEvent( - fastify: FastifyInstance, - payload: Record, - deliveryId: string -) { - const ref = payload.ref as string - const after = payload.after as string - const repository = payload.repository as { id: number; full_name: string } - - const branchName = extractBranchFromRef(ref) - if (!branchName) { - fastify.log.info(`Ignoring non-branch ref: ${ref}`) - return - } - - // Check if branch was deleted - if (isBranchDeletion(after)) { - fastify.log.info(`Branch deleted: ${branchName} in ${repository.full_name}`) - - // Find and clean up the branch - const repo = await db.query.repositories.findFirst({ - where: eq(repositories.githubId, repository.id), - }) - - if (repo) { - const branch = await db.query.branches.findFirst({ - where: and( - eq(branches.repositoryId, repo.id), - eq(branches.name, branchName) - ), - }) - - if (branch) { - // Delete branch files first (cascade should handle this but being explicit) - await db.delete(branchFiles).where(eq(branchFiles.branchId, branch.id)) - await db.delete(branches).where(eq(branches.id, branch.id)) - fastify.log.info(`Cleaned up deleted branch: ${branchName}`) - } - } - return - } - - fastify.log.info(`Push to ${branchName} in ${repository.full_name}`) -} - -async function handlePullRequestEvent( - fastify: FastifyInstance, - payload: Record, - deliveryId: string -) { - const action = payload.action as string - const prData = payload.pull_request as { - number: number - title: string - head: { ref: string } - } - - // Only process relevant actions - const relevantActions = [PR_ACTIONS.OPENED, PR_ACTIONS.SYNCHRONIZE, PR_ACTIONS.REOPENED] - if (!relevantActions.includes(action as typeof PR_ACTIONS.OPENED)) { - fastify.log.info(`Ignoring PR action: ${action}`) - return - } - - fastify.log.info(`PR ${action}: #${prData.number} (${prData.head.ref})`) -} - -async function handleInstallationEvent( - fastify: FastifyInstance, - payload: Record, - deliveryId: string -) { - const action = payload.action as string - const installation = payload.installation as { id: number; account: { login: string } } - - fastify.log.info( - `Installation ${action}: ${installation.id} for ${installation.account.login}` - ) -} diff --git a/apps/api/src/scheduler.ts b/apps/api/src/scheduler.ts deleted file mode 100644 index 14b5eef..0000000 --- a/apps/api/src/scheduler.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Queue } from 'bullmq' -import { Redis } from 'ioredis' -import { QUEUE_NAMES } from '@overlap/shared' -import type { MaintenanceJob } from '@overlap/shared' - -const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' - -export async function setupScheduler() { - const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }) - - const maintenanceQueue = new Queue(QUEUE_NAMES.MAINTENANCE, { - connection, - }) - - // Schedule branch pruning every 6 hours - await maintenanceQueue.upsertJobScheduler( - 'prune-branches', - { - every: 6 * 60 * 60 * 1000, // 6 hours - }, - { - name: 'prune_branches', - data: { type: 'prune_branches' }, - } - ) - - // Schedule event cleanup daily - await maintenanceQueue.upsertJobScheduler( - 'cleanup-events', - { - every: 24 * 60 * 60 * 1000, // 24 hours - }, - { - name: 'cleanup_events', - data: { type: 'cleanup_events' }, - } - ) - - console.log('Scheduled maintenance jobs configured') - - return { maintenanceQueue } -} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json deleted file mode 100644 index 90d76d7..0000000 --- a/apps/api/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"] -} diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts deleted file mode 100644 index 61a1e2a..0000000 --- a/apps/api/tsup.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: ['src/index.ts'], - format: ['esm'], - clean: true, - noExternal: ['@overlap/shared', '@overlap/db', '@overlap/github'], -}) diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..9271cbd --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,4 @@ +/.swc +# Generated by the workflow Vitest plugin: bundles and Local World state. +/.workflow-vitest +/.workflow-data diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile deleted file mode 100644 index e02a2d5..0000000 --- a/apps/web/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -FROM node:20-slim - -# Install pnpm -RUN npm install -g pnpm - -WORKDIR /app - -# Copy workspace config files -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ - -# Copy packages and app -COPY packages/ ./packages/ -COPY apps/web/ ./apps/web/ - -# Install dependencies -RUN pnpm install --frozen-lockfile - -# Vite bakes VITE_* env vars into the bundle at build time -ARG VITE_API_URL -ARG VITE_GITHUB_APP_SLUG -ARG VITE_VAPID_PUBLIC_KEY -ENV VITE_API_URL=$VITE_API_URL -ENV VITE_GITHUB_APP_SLUG=$VITE_GITHUB_APP_SLUG -ENV VITE_VAPID_PUBLIC_KEY=$VITE_VAPID_PUBLIC_KEY - -# Build web app (Vite handles workspace dependencies) -RUN pnpm --filter @overlap/web build - -# Set port for Nitro server (Railway will route traffic here) -ENV PORT=8080 -EXPOSE 8080 - -# Start the web app -CMD ["pnpm", "--filter", "@overlap/web", "start"] diff --git a/apps/web/package.json b/apps/web/package.json index b59c75a..b3bbd75 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,11 +6,15 @@ "scripts": { "dev": "vite", "build": "vite build", - "start": "node .output/server/index.mjs", "typecheck": "tsc --noEmit", - "lint": "eslint src/" + "lint": "eslint src/", + "test": "vitest run", + "test:watch": "vitest", + "test:integration": "vitest run --config vitest.integration.config.ts" }, "dependencies": { + "@overlap/db": "workspace:^", + "@overlap/github": "workspace:*", "@overlap/shared": "workspace:*", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.0", @@ -25,21 +29,30 @@ "@tanstack/react-start": "^1.158.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "drizzle-orm": "^0.38.0", + "jose": "^6.2.8", "lucide-react": "^0.470.0", + "minimatch": "^10.0.0", "nitro": "3.0.1-alpha.2", "react": "^19.0.0", "react-dom": "^19.0.0", - "tailwind-merge": "^2.6.0" + "tailwind-merge": "^2.6.0", + "web-push": "^3.6.7", + "workflow": "^4.8.2" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", "@types/node": "^20.11.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.1.0", + "@workflow/vitest": "4.0.18", + "esbuild": "^0.28.2", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", "vite": "^7.3.0", - "vite-tsconfig-paths": "^5.1.0" + "vite-tsconfig-paths": "^5.1.0", + "vitest": "^4.1.10" } } diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index ddfee3c..71640bf 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -1,4 +1,4 @@ -// Overlap Service Worker — handles push notifications +// Overlap Service Worker - handles push notifications self.addEventListener('push', (event) => { if (!event.data) return diff --git a/apps/web/public/theme-init.js b/apps/web/public/theme-init.js index 743a15c..9122a5e 100644 --- a/apps/web/public/theme-init.js +++ b/apps/web/public/theme-init.js @@ -1,5 +1,5 @@ // Apply dark mode class before first paint to prevent flash of wrong theme. -// Dark mode is the default — only skip if user explicitly chose light. +// Dark mode is the default - only skip if user explicitly chose light. (function () { var theme = localStorage.getItem('overlap-theme') if (theme === 'light') return diff --git a/apps/web/railway.json b/apps/web/railway.json deleted file mode 100644 index 4f7584b..0000000 --- a/apps/web/railway.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "apps/web/Dockerfile" - }, - "deploy": { - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 3 - } -} diff --git a/apps/web/railway.toml b/apps/web/railway.toml deleted file mode 100644 index 0751166..0000000 --- a/apps/web/railway.toml +++ /dev/null @@ -1,8 +0,0 @@ -[build] -builder = "nixpacks" -buildCommand = "cd ../.. && pnpm install && pnpm --filter @overlap/web build" - -[deploy] -startCommand = "pnpm --filter @overlap/web start" -restartPolicyType = "ON_FAILURE" -restartPolicyMaxRetries = 3 diff --git a/apps/web/src/components/protected-route.tsx b/apps/web/src/components/protected-route.tsx index 5d424e8..ed61ae8 100644 --- a/apps/web/src/components/protected-route.tsx +++ b/apps/web/src/components/protected-route.tsx @@ -35,7 +35,7 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) { ) } - // Already redirected once — show CTA + // Already redirected once - show CTA return (
diff --git a/apps/web/src/lib/__tests__/auth.test.ts b/apps/web/src/lib/__tests__/auth.test.ts new file mode 100644 index 0000000..51741cf --- /dev/null +++ b/apps/web/src/lib/__tests__/auth.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { readCookie, buildSessionCookie, buildClearCookie } from '../auth' + +function reqWithCookie(value: string): Request { + return new Request('https://example.com/', { headers: { cookie: value } }) +} + +describe('readCookie', () => { + it('reads a single cookie', () => { + expect(readCookie(reqWithCookie('session=abc'), 'session')).toBe('abc') + }) + + it('reads one cookie among several', () => { + const r = reqWithCookie('a=1; session=abc; b=2') + expect(readCookie(r, 'session')).toBe('abc') + }) + + it('returns null when absent', () => { + expect(readCookie(reqWithCookie('a=1'), 'session')).toBeNull() + }) + + it('returns null when there is no cookie header', () => { + expect(readCookie(new Request('https://example.com/'), 'session')).toBeNull() + }) + + it('does not match a cookie whose name is a suffix', () => { + expect(readCookie(reqWithCookie('oauth_session=abc'), 'session')).toBeNull() + }) +}) + +describe('buildSessionCookie', () => { + it('sets HttpOnly, SameSite=Lax and Path', () => { + const c = buildSessionCookie('tok') + expect(c).toContain('session=tok') + expect(c).toContain('HttpOnly') + expect(c).toContain('SameSite=Lax') + expect(c).toContain('Path=/') + expect(c).toContain('Max-Age=604800') + }) +}) + +describe('buildClearCookie', () => { + it('expires the cookie immediately', () => { + expect(buildClearCookie('session')).toContain('Max-Age=0') + }) +}) diff --git a/apps/web/src/lib/__tests__/cron-auth.test.ts b/apps/web/src/lib/__tests__/cron-auth.test.ts new file mode 100644 index 0000000..1bcf55f --- /dev/null +++ b/apps/web/src/lib/__tests__/cron-auth.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { isAuthorizedCron } from '../cron-auth' + +beforeEach(() => { + process.env.CRON_SECRET = 'correct-secret' +}) + +function req(auth?: string): Request { + return new Request('https://example.com/api/cron/prune-branches', { + headers: auth ? { authorization: auth } : {}, + }) +} + +describe('isAuthorizedCron', () => { + it('accepts the correct bearer token', () => { + expect(isAuthorizedCron(req('Bearer correct-secret'))).toBe(true) + }) + + it('rejects a wrong token of the same length', () => { + expect(isAuthorizedCron(req('Bearer wrongxxsecret!'))).toBe(false) + }) + + it('rejects a wrong token of a different length', () => { + expect(isAuthorizedCron(req('Bearer short'))).toBe(false) + }) + + it('rejects a missing header', () => { + expect(isAuthorizedCron(req())).toBe(false) + }) + + it('rejects when CRON_SECRET is unset', () => { + delete process.env.CRON_SECRET + expect(isAuthorizedCron(req('Bearer anything'))).toBe(false) + }) + + it('rejects a lowercase "bearer" prefix', () => { + expect(isAuthorizedCron(req('bearer correct-secret'))).toBe(false) + }) + + it('rejects a header with no space after Bearer', () => { + expect(isAuthorizedCron(req('Bearercorrect-secret'))).toBe(false) + }) + + it('rejects an empty token after the Bearer prefix', () => { + expect(isAuthorizedCron(req('Bearer '))).toBe(false) + }) + + it('rejects extra whitespace between the prefix and the token', () => { + // Leading/trailing whitespace on the header value is trimmed by the + // Fetch Headers implementation, but internal whitespace survives, so + // a double space here still reaches isAuthorizedCron and must fail. + expect(isAuthorizedCron(req('Bearer correct-secret'))).toBe(false) + }) + + it('rejects a completely unrelated auth scheme', () => { + expect(isAuthorizedCron(req('Basic correct-secret'))).toBe(false) + }) +}) diff --git a/apps/web/src/lib/__tests__/session.test.ts b/apps/web/src/lib/__tests__/session.test.ts new file mode 100644 index 0000000..cec87c5 --- /dev/null +++ b/apps/web/src/lib/__tests__/session.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { signSession, verifySession } from '../session' + +beforeAll(() => { + process.env.SESSION_SECRET = 'test-secret-value-at-least-32-bytes-long' +}) + +describe('session', () => { + it('round-trips a userId', async () => { + const token = await signSession('user-123') + const result = await verifySession(token) + expect(result).toEqual({ userId: 'user-123' }) + }) + + it('rejects a tampered token', async () => { + const token = await signSession('user-123') + const tampered = token.slice(0, -4) + 'aaaa' + expect(await verifySession(tampered)).toBeNull() + }) + + it('rejects a token signed with a different secret', async () => { + const token = await signSession('user-123') + process.env.SESSION_SECRET = 'a-completely-different-secret-value-32b' + const result = await verifySession(token) + process.env.SESSION_SECRET = 'test-secret-value-at-least-32-bytes-long' + expect(result).toBeNull() + }) + + it('rejects a malformed token', async () => { + expect(await verifySession('not-a-jwt')).toBeNull() + }) + + it('carries no claims beyond userId, iat and exp', async () => { + const token = await signSession('user-123') + const payload = JSON.parse( + Buffer.from(token.split('.')[1], 'base64url').toString() + ) + expect(Object.keys(payload).sort()).toEqual(['exp', 'iat', 'userId']) + }) +}) diff --git a/apps/web/src/lib/__tests__/smoke.test.ts b/apps/web/src/lib/__tests__/smoke.test.ts new file mode 100644 index 0000000..f00d55b --- /dev/null +++ b/apps/web/src/lib/__tests__/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest' + +describe('test harness', () => { + it('runs', () => { + expect(1 + 1).toBe(2) + }) +}) diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 5b39c75..2c292e7 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -42,9 +42,9 @@ export const api = { avatarUrl: string | null } hasInstallations: boolean - }>('/auth/me'), + }>('/api/auth/me'), - logout: () => request<{ success: boolean }>('/auth/logout', { method: 'POST' }), + logout: () => request<{ success: boolean }>('/api/auth/logout', { method: 'POST' }), // Repositories getRepositories: () => @@ -144,13 +144,13 @@ export const api = { // Push notifications subscribePush: (subscription: { endpoint: string; keys: { p256dh: string; auth: string } }) => - request('/api/push/subscribe', { + request('/api/push', { method: 'POST', body: JSON.stringify(subscription), }), unsubscribePush: (endpoint: string) => - request('/api/push/unsubscribe', { + request('/api/push', { method: 'DELETE', body: JSON.stringify({ endpoint }), }), diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..853c9b0 --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,83 @@ +import { db, users } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { + verifySession, + SESSION_COOKIE_NAME, + SESSION_MAX_AGE_SECONDS, +} from './session' + +export type AuthUser = { + id: string + githubId: number + username: string + email: string | null + avatarUrl: string | null +} + +export function readCookie(request: Request, name: string): string | null { + const header = request.headers.get('cookie') + if (!header) return null + for (const part of header.split(';')) { + const eq = part.indexOf('=') + if (eq === -1) continue + if (part.slice(0, eq).trim() === name) { + return part.slice(eq + 1).trim() + } + } + return null +} + +function isProduction(): boolean { + return process.env.NODE_ENV === 'production' +} + +export function buildSessionCookie(token: string): string { + const parts = [ + `${SESSION_COOKIE_NAME}=${token}`, + 'HttpOnly', + 'SameSite=Lax', + 'Path=/', + `Max-Age=${SESSION_MAX_AGE_SECONDS}`, + ] + if (isProduction()) parts.push('Secure') + return parts.join('; ') +} + +export function buildClearCookie(name: string): string { + const parts = [`${name}=`, 'HttpOnly', 'SameSite=Lax', 'Path=/', 'Max-Age=0'] + if (isProduction()) parts.push('Secure') + return parts.join('; ') +} + +export async function getUser(request: Request): Promise { + const token = readCookie(request, SESSION_COOKIE_NAME) + if (!token) return null + + const session = await verifySession(token) + if (!session) return null + + // Retained deliberately: this lookup is what makes revocation immediate. + const user = await db.query.users.findFirst({ + where: eq(users.id, session.userId), + }) + if (!user) return null + + return { + id: user.id, + githubId: user.githubId, + username: user.username, + email: user.email, + avatarUrl: user.avatarUrl, + } +} + +export async function requireUser(request: Request): Promise { + const user = await getUser(request) + if (!user) { + throw new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }) + } + return user +} diff --git a/apps/web/src/lib/cron-auth.ts b/apps/web/src/lib/cron-auth.ts new file mode 100644 index 0000000..20d01d8 --- /dev/null +++ b/apps/web/src/lib/cron-auth.ts @@ -0,0 +1,28 @@ +import { timingSafeEqual } from 'node:crypto' + +/** + * Authorizes a Vercel Cron request. + * + * Cron endpoints are publicly routable, and the ones that use this guard + * kick off workflows that iterate every active repository, so a forged + * request amplifies both cost and database load. The comparison uses + * `timingSafeEqual` rather than `===` because `===` short-circuits on the + * first differing byte, leaking the secret through response timing. + * + * `timingSafeEqual` throws on a length mismatch, so lengths are compared + * first. The length itself is not secret; only the contents are. + */ +export function isAuthorizedCron(request: Request): boolean { + const secret = process.env.CRON_SECRET + if (!secret) return false + + const header = request.headers.get('authorization') + if (!header?.startsWith('Bearer ')) return false + + const provided = Buffer.from(header.slice('Bearer '.length)) + const expected = Buffer.from(secret) + + if (provided.length !== expected.length) return false + + return timingSafeEqual(provided, expected) +} diff --git a/apps/web/src/lib/github-oauth.ts b/apps/web/src/lib/github-oauth.ts new file mode 100644 index 0000000..b215d6f --- /dev/null +++ b/apps/web/src/lib/github-oauth.ts @@ -0,0 +1,111 @@ +import { db, githubAppInstallations, userInstallations, repositories, repositorySettings } from '@overlap/db' + +export async function syncUserInstallations(accessToken: string, userId: string) { + try { + const res = await fetch('https://api.github.com/user/installations', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + }, + }) + + if (!res.ok) return + + const data = (await res.json()) as { + installations: Array<{ + id: number + account: { login: string; type: string } + }> + } + + for (const inst of data.installations) { + const [installation] = await db + .insert(githubAppInstallations) + .values({ + installationId: inst.id, + userId, + status: 'active', + }) + .onConflictDoUpdate({ + target: githubAppInstallations.installationId, + set: { + status: 'active', + updatedAt: new Date(), + }, + }) + .returning() + + // Link user to installation (many-to-many) + await db + .insert(userInstallations) + .values({ userId, installationId: installation.id }) + .onConflictDoNothing() + + // Sync repos for this installation + await syncInstallationRepos(accessToken, inst.id, installation.id) + } + } catch (err) { + console.error('Failed to sync installations:', err) + } +} + +async function syncInstallationRepos(accessToken: string, installationId: number, dbInstallationId: string) { + try { + const res = await fetch( + `https://api.github.com/user/installations/${installationId}/repositories`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + }, + } + ) + + if (!res.ok) return + + const data = (await res.json()) as { + repositories: Array<{ + id: number + name: string + full_name: string + private: boolean + default_branch: string + }> + } + + for (const repo of data.repositories) { + const [inserted] = await db + .insert(repositories) + .values({ + githubId: repo.id, + installationId: dbInstallationId, + name: repo.name, + fullName: repo.full_name, + defaultBranch: repo.default_branch, + isPrivate: repo.private, + isActive: true, + }) + .onConflictDoUpdate({ + target: repositories.githubId, + set: { + installationId: dbInstallationId, + name: repo.name, + fullName: repo.full_name, + defaultBranch: repo.default_branch, + isPrivate: repo.private, + isActive: true, + updatedAt: new Date(), + }, + }) + .returning() + + // Ensure default settings exist + await db + .insert(repositorySettings) + .values({ repositoryId: inserted.id }) + .onConflictDoNothing() + } + } catch (err) { + console.error(`Failed to sync repos for installation ${installationId}:`, err) + } +} diff --git a/apps/web/src/lib/repo-access.ts b/apps/web/src/lib/repo-access.ts new file mode 100644 index 0000000..f830ec0 --- /dev/null +++ b/apps/web/src/lib/repo-access.ts @@ -0,0 +1,37 @@ +import { db, repositories, userInstallations } from '@overlap/db' +import { eq, and, inArray, sql } from 'drizzle-orm' +import type { AuthUser } from './auth' + +// Helper to get user's installation IDs (via many-to-many join table) +export async function getUserInstallationIds(userId: string): Promise { + const links = await db.query.userInstallations.findMany({ + where: eq(userInstallations.userId, userId), + with: { installation: true }, + }) + return links + .filter((l) => l.installation.status === 'active') + .map((l) => l.installationId) +} + +// Helper to verify the authenticated user has access to a repository +export async function requireRepoAccess( + user: AuthUser, + repoId: string +): Promise { + const installationIds = await getUserInstallationIds(user.id) + const repo = await db.query.repositories.findFirst({ + where: and( + eq(repositories.id, repoId), + installationIds.length > 0 + ? inArray(repositories.installationId, installationIds) + : sql`false` + ), + }) + if (!repo) { + throw new Response(JSON.stringify({ error: 'Repository not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }) + } + return repo +} diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts new file mode 100644 index 0000000..cf19809 --- /dev/null +++ b/apps/web/src/lib/session.ts @@ -0,0 +1,37 @@ +import { SignJWT, jwtVerify } from 'jose' + +export const SESSION_COOKIE_NAME = 'session' +export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7 + +const ALGORITHM = 'HS256' + +function getKey(): Uint8Array { + const secret = process.env.SESSION_SECRET + if (!secret) { + throw new Error('SESSION_SECRET environment variable is required') + } + return new TextEncoder().encode(secret) +} + +export async function signSession(userId: string): Promise { + return new SignJWT({ userId }) + .setProtectedHeader({ alg: ALGORITHM }) + .setIssuedAt() + .setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`) + .sign(getKey()) +} + +export async function verifySession( + token: string +): Promise<{ userId: string } | null> { + try { + const { payload } = await jwtVerify(token, getKey(), { + algorithms: [ALGORITHM], + }) + const userId = payload.userId + if (typeof userId !== 'string') return null + return { userId } + } catch { + return null + } +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index df11dd5..2d4efbc 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,6 +14,23 @@ import { Route as RepositoriesRouteImport } from './routes/repositories' import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' import { Route as RepositoriesRepoIdRouteImport } from './routes/repositories_.$repoId' +import { Route as ApiRepositoriesRouteImport } from './routes/api/repositories' +import { Route as ApiPushRouteImport } from './routes/api/push' +import { Route as ApiHealthRouteImport } from './routes/api/health' +import { Route as ApiWebhooksGithubRouteImport } from './routes/api/webhooks/github' +import { Route as ApiRepositoriesIdRouteImport } from './routes/api/repositories.$id' +import { Route as ApiCronPruneBranchesRouteImport } from './routes/api/cron/prune-branches' +import { Route as ApiCronCleanupEventsRouteImport } from './routes/api/cron/cleanup-events' +import { Route as ApiAuthMeRouteImport } from './routes/api/auth/me' +import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout' +import { Route as ApiAuthGithubRouteImport } from './routes/api/auth/github' +import { Route as ApiRepositoriesIdSettingsRouteImport } from './routes/api/repositories.$id.settings' +import { Route as ApiRepositoriesIdOverlapsRouteImport } from './routes/api/repositories.$id.overlaps' +import { Route as ApiRepositoriesIdDiffsRouteImport } from './routes/api/repositories.$id.diffs' +import { Route as ApiRepositoriesIdBranchesRouteImport } from './routes/api/repositories.$id.branches' +import { Route as ApiAuthGithubCallbackRouteImport } from './routes/api/auth/github.callback' +import { Route as ApiRepositoriesIdOverlapsOverlapIdRouteImport } from './routes/api/repositories.$id.overlaps.$overlapId' +import { Route as ApiRepositoriesIdOverlapsOverlapIdTestNotifyRouteImport } from './routes/api/repositories.$id.overlaps.$overlapId.test-notify' const SettingsRoute = SettingsRouteImport.update({ id: '/settings', @@ -40,20 +57,144 @@ const RepositoriesRepoIdRoute = RepositoriesRepoIdRouteImport.update({ path: '/repositories/$repoId', getParentRoute: () => rootRouteImport, } as any) +const ApiRepositoriesRoute = ApiRepositoriesRouteImport.update({ + id: '/api/repositories', + path: '/api/repositories', + getParentRoute: () => rootRouteImport, +} as any) +const ApiPushRoute = ApiPushRouteImport.update({ + id: '/api/push', + path: '/api/push', + getParentRoute: () => rootRouteImport, +} as any) +const ApiHealthRoute = ApiHealthRouteImport.update({ + id: '/api/health', + path: '/api/health', + getParentRoute: () => rootRouteImport, +} as any) +const ApiWebhooksGithubRoute = ApiWebhooksGithubRouteImport.update({ + id: '/api/webhooks/github', + path: '/api/webhooks/github', + getParentRoute: () => rootRouteImport, +} as any) +const ApiRepositoriesIdRoute = ApiRepositoriesIdRouteImport.update({ + id: '/$id', + path: '/$id', + getParentRoute: () => ApiRepositoriesRoute, +} as any) +const ApiCronPruneBranchesRoute = ApiCronPruneBranchesRouteImport.update({ + id: '/api/cron/prune-branches', + path: '/api/cron/prune-branches', + getParentRoute: () => rootRouteImport, +} as any) +const ApiCronCleanupEventsRoute = ApiCronCleanupEventsRouteImport.update({ + id: '/api/cron/cleanup-events', + path: '/api/cron/cleanup-events', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAuthMeRoute = ApiAuthMeRouteImport.update({ + id: '/api/auth/me', + path: '/api/auth/me', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAuthLogoutRoute = ApiAuthLogoutRouteImport.update({ + id: '/api/auth/logout', + path: '/api/auth/logout', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAuthGithubRoute = ApiAuthGithubRouteImport.update({ + id: '/api/auth/github', + path: '/api/auth/github', + getParentRoute: () => rootRouteImport, +} as any) +const ApiRepositoriesIdSettingsRoute = + ApiRepositoriesIdSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => ApiRepositoriesIdRoute, + } as any) +const ApiRepositoriesIdOverlapsRoute = + ApiRepositoriesIdOverlapsRouteImport.update({ + id: '/overlaps', + path: '/overlaps', + getParentRoute: () => ApiRepositoriesIdRoute, + } as any) +const ApiRepositoriesIdDiffsRoute = ApiRepositoriesIdDiffsRouteImport.update({ + id: '/diffs', + path: '/diffs', + getParentRoute: () => ApiRepositoriesIdRoute, +} as any) +const ApiRepositoriesIdBranchesRoute = + ApiRepositoriesIdBranchesRouteImport.update({ + id: '/branches', + path: '/branches', + getParentRoute: () => ApiRepositoriesIdRoute, + } as any) +const ApiAuthGithubCallbackRoute = ApiAuthGithubCallbackRouteImport.update({ + id: '/callback', + path: '/callback', + getParentRoute: () => ApiAuthGithubRoute, +} as any) +const ApiRepositoriesIdOverlapsOverlapIdRoute = + ApiRepositoriesIdOverlapsOverlapIdRouteImport.update({ + id: '/$overlapId', + path: '/$overlapId', + getParentRoute: () => ApiRepositoriesIdOverlapsRoute, + } as any) +const ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute = + ApiRepositoriesIdOverlapsOverlapIdTestNotifyRouteImport.update({ + id: '/test-notify', + path: '/test-notify', + getParentRoute: () => ApiRepositoriesIdOverlapsOverlapIdRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute '/repositories': typeof RepositoriesRoute '/settings': typeof SettingsRoute + '/api/health': typeof ApiHealthRoute + '/api/push': typeof ApiPushRoute + '/api/repositories': typeof ApiRepositoriesRouteWithChildren '/repositories/$repoId': typeof RepositoriesRepoIdRoute + '/api/auth/github': typeof ApiAuthGithubRouteWithChildren + '/api/auth/logout': typeof ApiAuthLogoutRoute + '/api/auth/me': typeof ApiAuthMeRoute + '/api/cron/cleanup-events': typeof ApiCronCleanupEventsRoute + '/api/cron/prune-branches': typeof ApiCronPruneBranchesRoute + '/api/repositories/$id': typeof ApiRepositoriesIdRouteWithChildren + '/api/webhooks/github': typeof ApiWebhooksGithubRoute + '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute + '/api/repositories/$id/branches': typeof ApiRepositoriesIdBranchesRoute + '/api/repositories/$id/diffs': typeof ApiRepositoriesIdDiffsRoute + '/api/repositories/$id/overlaps': typeof ApiRepositoriesIdOverlapsRouteWithChildren + '/api/repositories/$id/settings': typeof ApiRepositoriesIdSettingsRoute + '/api/repositories/$id/overlaps/$overlapId': typeof ApiRepositoriesIdOverlapsOverlapIdRouteWithChildren + '/api/repositories/$id/overlaps/$overlapId/test-notify': typeof ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute '/repositories': typeof RepositoriesRoute '/settings': typeof SettingsRoute + '/api/health': typeof ApiHealthRoute + '/api/push': typeof ApiPushRoute + '/api/repositories': typeof ApiRepositoriesRouteWithChildren '/repositories/$repoId': typeof RepositoriesRepoIdRoute + '/api/auth/github': typeof ApiAuthGithubRouteWithChildren + '/api/auth/logout': typeof ApiAuthLogoutRoute + '/api/auth/me': typeof ApiAuthMeRoute + '/api/cron/cleanup-events': typeof ApiCronCleanupEventsRoute + '/api/cron/prune-branches': typeof ApiCronPruneBranchesRoute + '/api/repositories/$id': typeof ApiRepositoriesIdRouteWithChildren + '/api/webhooks/github': typeof ApiWebhooksGithubRoute + '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute + '/api/repositories/$id/branches': typeof ApiRepositoriesIdBranchesRoute + '/api/repositories/$id/diffs': typeof ApiRepositoriesIdDiffsRoute + '/api/repositories/$id/overlaps': typeof ApiRepositoriesIdOverlapsRouteWithChildren + '/api/repositories/$id/settings': typeof ApiRepositoriesIdSettingsRoute + '/api/repositories/$id/overlaps/$overlapId': typeof ApiRepositoriesIdOverlapsOverlapIdRouteWithChildren + '/api/repositories/$id/overlaps/$overlapId/test-notify': typeof ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -61,7 +202,24 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/repositories': typeof RepositoriesRoute '/settings': typeof SettingsRoute + '/api/health': typeof ApiHealthRoute + '/api/push': typeof ApiPushRoute + '/api/repositories': typeof ApiRepositoriesRouteWithChildren '/repositories_/$repoId': typeof RepositoriesRepoIdRoute + '/api/auth/github': typeof ApiAuthGithubRouteWithChildren + '/api/auth/logout': typeof ApiAuthLogoutRoute + '/api/auth/me': typeof ApiAuthMeRoute + '/api/cron/cleanup-events': typeof ApiCronCleanupEventsRoute + '/api/cron/prune-branches': typeof ApiCronPruneBranchesRoute + '/api/repositories/$id': typeof ApiRepositoriesIdRouteWithChildren + '/api/webhooks/github': typeof ApiWebhooksGithubRoute + '/api/auth/github/callback': typeof ApiAuthGithubCallbackRoute + '/api/repositories/$id/branches': typeof ApiRepositoriesIdBranchesRoute + '/api/repositories/$id/diffs': typeof ApiRepositoriesIdDiffsRoute + '/api/repositories/$id/overlaps': typeof ApiRepositoriesIdOverlapsRouteWithChildren + '/api/repositories/$id/settings': typeof ApiRepositoriesIdSettingsRoute + '/api/repositories/$id/overlaps/$overlapId': typeof ApiRepositoriesIdOverlapsOverlapIdRouteWithChildren + '/api/repositories/$id/overlaps/$overlapId/test-notify': typeof ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -70,16 +228,72 @@ export interface FileRouteTypes { | '/login' | '/repositories' | '/settings' + | '/api/health' + | '/api/push' + | '/api/repositories' | '/repositories/$repoId' + | '/api/auth/github' + | '/api/auth/logout' + | '/api/auth/me' + | '/api/cron/cleanup-events' + | '/api/cron/prune-branches' + | '/api/repositories/$id' + | '/api/webhooks/github' + | '/api/auth/github/callback' + | '/api/repositories/$id/branches' + | '/api/repositories/$id/diffs' + | '/api/repositories/$id/overlaps' + | '/api/repositories/$id/settings' + | '/api/repositories/$id/overlaps/$overlapId' + | '/api/repositories/$id/overlaps/$overlapId/test-notify' fileRoutesByTo: FileRoutesByTo - to: '/' | '/login' | '/repositories' | '/settings' | '/repositories/$repoId' + to: + | '/' + | '/login' + | '/repositories' + | '/settings' + | '/api/health' + | '/api/push' + | '/api/repositories' + | '/repositories/$repoId' + | '/api/auth/github' + | '/api/auth/logout' + | '/api/auth/me' + | '/api/cron/cleanup-events' + | '/api/cron/prune-branches' + | '/api/repositories/$id' + | '/api/webhooks/github' + | '/api/auth/github/callback' + | '/api/repositories/$id/branches' + | '/api/repositories/$id/diffs' + | '/api/repositories/$id/overlaps' + | '/api/repositories/$id/settings' + | '/api/repositories/$id/overlaps/$overlapId' + | '/api/repositories/$id/overlaps/$overlapId/test-notify' id: | '__root__' | '/' | '/login' | '/repositories' | '/settings' + | '/api/health' + | '/api/push' + | '/api/repositories' | '/repositories_/$repoId' + | '/api/auth/github' + | '/api/auth/logout' + | '/api/auth/me' + | '/api/cron/cleanup-events' + | '/api/cron/prune-branches' + | '/api/repositories/$id' + | '/api/webhooks/github' + | '/api/auth/github/callback' + | '/api/repositories/$id/branches' + | '/api/repositories/$id/diffs' + | '/api/repositories/$id/overlaps' + | '/api/repositories/$id/settings' + | '/api/repositories/$id/overlaps/$overlapId' + | '/api/repositories/$id/overlaps/$overlapId/test-notify' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -87,7 +301,16 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute RepositoriesRoute: typeof RepositoriesRoute SettingsRoute: typeof SettingsRoute + ApiHealthRoute: typeof ApiHealthRoute + ApiPushRoute: typeof ApiPushRoute + ApiRepositoriesRoute: typeof ApiRepositoriesRouteWithChildren RepositoriesRepoIdRoute: typeof RepositoriesRepoIdRoute + ApiAuthGithubRoute: typeof ApiAuthGithubRouteWithChildren + ApiAuthLogoutRoute: typeof ApiAuthLogoutRoute + ApiAuthMeRoute: typeof ApiAuthMeRoute + ApiCronCleanupEventsRoute: typeof ApiCronCleanupEventsRoute + ApiCronPruneBranchesRoute: typeof ApiCronPruneBranchesRoute + ApiWebhooksGithubRoute: typeof ApiWebhooksGithubRoute } declare module '@tanstack/react-router' { @@ -127,15 +350,214 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RepositoriesRepoIdRouteImport parentRoute: typeof rootRouteImport } + '/api/repositories': { + id: '/api/repositories' + path: '/api/repositories' + fullPath: '/api/repositories' + preLoaderRoute: typeof ApiRepositoriesRouteImport + parentRoute: typeof rootRouteImport + } + '/api/push': { + id: '/api/push' + path: '/api/push' + fullPath: '/api/push' + preLoaderRoute: typeof ApiPushRouteImport + parentRoute: typeof rootRouteImport + } + '/api/health': { + id: '/api/health' + path: '/api/health' + fullPath: '/api/health' + preLoaderRoute: typeof ApiHealthRouteImport + parentRoute: typeof rootRouteImport + } + '/api/webhooks/github': { + id: '/api/webhooks/github' + path: '/api/webhooks/github' + fullPath: '/api/webhooks/github' + preLoaderRoute: typeof ApiWebhooksGithubRouteImport + parentRoute: typeof rootRouteImport + } + '/api/repositories/$id': { + id: '/api/repositories/$id' + path: '/$id' + fullPath: '/api/repositories/$id' + preLoaderRoute: typeof ApiRepositoriesIdRouteImport + parentRoute: typeof ApiRepositoriesRoute + } + '/api/cron/prune-branches': { + id: '/api/cron/prune-branches' + path: '/api/cron/prune-branches' + fullPath: '/api/cron/prune-branches' + preLoaderRoute: typeof ApiCronPruneBranchesRouteImport + parentRoute: typeof rootRouteImport + } + '/api/cron/cleanup-events': { + id: '/api/cron/cleanup-events' + path: '/api/cron/cleanup-events' + fullPath: '/api/cron/cleanup-events' + preLoaderRoute: typeof ApiCronCleanupEventsRouteImport + parentRoute: typeof rootRouteImport + } + '/api/auth/me': { + id: '/api/auth/me' + path: '/api/auth/me' + fullPath: '/api/auth/me' + preLoaderRoute: typeof ApiAuthMeRouteImport + parentRoute: typeof rootRouteImport + } + '/api/auth/logout': { + id: '/api/auth/logout' + path: '/api/auth/logout' + fullPath: '/api/auth/logout' + preLoaderRoute: typeof ApiAuthLogoutRouteImport + parentRoute: typeof rootRouteImport + } + '/api/auth/github': { + id: '/api/auth/github' + path: '/api/auth/github' + fullPath: '/api/auth/github' + preLoaderRoute: typeof ApiAuthGithubRouteImport + parentRoute: typeof rootRouteImport + } + '/api/repositories/$id/settings': { + id: '/api/repositories/$id/settings' + path: '/settings' + fullPath: '/api/repositories/$id/settings' + preLoaderRoute: typeof ApiRepositoriesIdSettingsRouteImport + parentRoute: typeof ApiRepositoriesIdRoute + } + '/api/repositories/$id/overlaps': { + id: '/api/repositories/$id/overlaps' + path: '/overlaps' + fullPath: '/api/repositories/$id/overlaps' + preLoaderRoute: typeof ApiRepositoriesIdOverlapsRouteImport + parentRoute: typeof ApiRepositoriesIdRoute + } + '/api/repositories/$id/diffs': { + id: '/api/repositories/$id/diffs' + path: '/diffs' + fullPath: '/api/repositories/$id/diffs' + preLoaderRoute: typeof ApiRepositoriesIdDiffsRouteImport + parentRoute: typeof ApiRepositoriesIdRoute + } + '/api/repositories/$id/branches': { + id: '/api/repositories/$id/branches' + path: '/branches' + fullPath: '/api/repositories/$id/branches' + preLoaderRoute: typeof ApiRepositoriesIdBranchesRouteImport + parentRoute: typeof ApiRepositoriesIdRoute + } + '/api/auth/github/callback': { + id: '/api/auth/github/callback' + path: '/callback' + fullPath: '/api/auth/github/callback' + preLoaderRoute: typeof ApiAuthGithubCallbackRouteImport + parentRoute: typeof ApiAuthGithubRoute + } + '/api/repositories/$id/overlaps/$overlapId': { + id: '/api/repositories/$id/overlaps/$overlapId' + path: '/$overlapId' + fullPath: '/api/repositories/$id/overlaps/$overlapId' + preLoaderRoute: typeof ApiRepositoriesIdOverlapsOverlapIdRouteImport + parentRoute: typeof ApiRepositoriesIdOverlapsRoute + } + '/api/repositories/$id/overlaps/$overlapId/test-notify': { + id: '/api/repositories/$id/overlaps/$overlapId/test-notify' + path: '/test-notify' + fullPath: '/api/repositories/$id/overlaps/$overlapId/test-notify' + preLoaderRoute: typeof ApiRepositoriesIdOverlapsOverlapIdTestNotifyRouteImport + parentRoute: typeof ApiRepositoriesIdOverlapsOverlapIdRoute + } } } +interface ApiRepositoriesIdOverlapsOverlapIdRouteChildren { + ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute: typeof ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute +} + +const ApiRepositoriesIdOverlapsOverlapIdRouteChildren: ApiRepositoriesIdOverlapsOverlapIdRouteChildren = + { + ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute: + ApiRepositoriesIdOverlapsOverlapIdTestNotifyRoute, + } + +const ApiRepositoriesIdOverlapsOverlapIdRouteWithChildren = + ApiRepositoriesIdOverlapsOverlapIdRoute._addFileChildren( + ApiRepositoriesIdOverlapsOverlapIdRouteChildren, + ) + +interface ApiRepositoriesIdOverlapsRouteChildren { + ApiRepositoriesIdOverlapsOverlapIdRoute: typeof ApiRepositoriesIdOverlapsOverlapIdRouteWithChildren +} + +const ApiRepositoriesIdOverlapsRouteChildren: ApiRepositoriesIdOverlapsRouteChildren = + { + ApiRepositoriesIdOverlapsOverlapIdRoute: + ApiRepositoriesIdOverlapsOverlapIdRouteWithChildren, + } + +const ApiRepositoriesIdOverlapsRouteWithChildren = + ApiRepositoriesIdOverlapsRoute._addFileChildren( + ApiRepositoriesIdOverlapsRouteChildren, + ) + +interface ApiRepositoriesIdRouteChildren { + ApiRepositoriesIdBranchesRoute: typeof ApiRepositoriesIdBranchesRoute + ApiRepositoriesIdDiffsRoute: typeof ApiRepositoriesIdDiffsRoute + ApiRepositoriesIdOverlapsRoute: typeof ApiRepositoriesIdOverlapsRouteWithChildren + ApiRepositoriesIdSettingsRoute: typeof ApiRepositoriesIdSettingsRoute +} + +const ApiRepositoriesIdRouteChildren: ApiRepositoriesIdRouteChildren = { + ApiRepositoriesIdBranchesRoute: ApiRepositoriesIdBranchesRoute, + ApiRepositoriesIdDiffsRoute: ApiRepositoriesIdDiffsRoute, + ApiRepositoriesIdOverlapsRoute: ApiRepositoriesIdOverlapsRouteWithChildren, + ApiRepositoriesIdSettingsRoute: ApiRepositoriesIdSettingsRoute, +} + +const ApiRepositoriesIdRouteWithChildren = + ApiRepositoriesIdRoute._addFileChildren(ApiRepositoriesIdRouteChildren) + +interface ApiRepositoriesRouteChildren { + ApiRepositoriesIdRoute: typeof ApiRepositoriesIdRouteWithChildren +} + +const ApiRepositoriesRouteChildren: ApiRepositoriesRouteChildren = { + ApiRepositoriesIdRoute: ApiRepositoriesIdRouteWithChildren, +} + +const ApiRepositoriesRouteWithChildren = ApiRepositoriesRoute._addFileChildren( + ApiRepositoriesRouteChildren, +) + +interface ApiAuthGithubRouteChildren { + ApiAuthGithubCallbackRoute: typeof ApiAuthGithubCallbackRoute +} + +const ApiAuthGithubRouteChildren: ApiAuthGithubRouteChildren = { + ApiAuthGithubCallbackRoute: ApiAuthGithubCallbackRoute, +} + +const ApiAuthGithubRouteWithChildren = ApiAuthGithubRoute._addFileChildren( + ApiAuthGithubRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LoginRoute: LoginRoute, RepositoriesRoute: RepositoriesRoute, SettingsRoute: SettingsRoute, + ApiHealthRoute: ApiHealthRoute, + ApiPushRoute: ApiPushRoute, + ApiRepositoriesRoute: ApiRepositoriesRouteWithChildren, RepositoriesRepoIdRoute: RepositoriesRepoIdRoute, + ApiAuthGithubRoute: ApiAuthGithubRouteWithChildren, + ApiAuthLogoutRoute: ApiAuthLogoutRoute, + ApiAuthMeRoute: ApiAuthMeRoute, + ApiCronCleanupEventsRoute: ApiCronCleanupEventsRoute, + ApiCronPruneBranchesRoute: ApiCronPruneBranchesRoute, + ApiWebhooksGithubRoute: ApiWebhooksGithubRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e3ba328..261ac00 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -76,12 +76,12 @@ function AppLayout() { return (
- {/* Desktop sidebar — hidden on mobile */} + {/* Desktop sidebar - hidden on mobile */}
- {/* Mobile header — shown only on mobile */} + {/* Mobile header - shown only on mobile */}
diff --git a/apps/web/src/routes/api/__tests__/repositories.overlaps.overlap-id.test.ts b/apps/web/src/routes/api/__tests__/repositories.overlaps.overlap-id.test.ts new file mode 100644 index 0000000..90211bc --- /dev/null +++ b/apps/web/src/routes/api/__tests__/repositories.overlaps.overlap-id.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +/** + * `params.overlapId` used to be read raw, while `params.id` always went + * through `repositoryIdParamSchema`. A non-UUID `overlapId` (e.g. a stray + * path segment, or a caller poking at the URL) reached the Drizzle query + * unvalidated, and Postgres rejected it with `22P02` ("invalid input syntax + * for type uuid"). That error escaped the route's `catch (res)` block - + * which only special-cases `res instanceof Response` - as an unhandled 500 + * instead of the 404 a bad id should produce. These tests pin the fix: a + * non-UUID `overlapId` now returns 404, the same as a well-formed id that + * simply does not exist, and never reaches the database. + */ + +const findFirst = vi.fn() +const updateSet = vi.fn() +const requireUser = vi.fn() +const requireRepoAccess = vi.fn() + +vi.mock('@overlap/db', () => ({ + db: { + query: { + overlaps: { findFirst: (...args: unknown[]) => findFirst(...args) }, + }, + update: () => ({ + set: (values: Record) => { + updateSet(values) + return { + where: () => ({ + returning: () => Promise.resolve([{ id: 'overlap-1', ...values }]), + }), + } + }, + }), + }, + overlaps: { id: 'id', repositoryId: 'repositoryId', sourceBranchId: 'sourceBranchId', targetBranchId: 'targetBranchId' }, +})) + +vi.mock('../../../lib/auth', () => ({ + requireUser: (...args: unknown[]) => requireUser(...args), +})) + +vi.mock('../../../lib/repo-access', () => ({ + requireRepoAccess: (...args: unknown[]) => requireRepoAccess(...args), +})) + +const { Route } = await import('../repositories.$id.overlaps.$overlapId') + +// TanStack Start's generic `RouteApi` type does not preserve the literal +// handler map shape once read back off `Route.options.server.handlers`, even +// though the object is exactly what the route file passed to +// `createFileRoute(...)`. This is a test-only escape hatch to call the PATCH +// handler directly, the same way it is invoked at runtime. +type PatchHandler = (ctx: { + request: Request + params: Record +}) => Promise + +const patch = (Route.options.server?.handlers as unknown as { PATCH: PatchHandler }).PATCH + +const REPO_ID = '11111111-1111-1111-1111-111111111111' +const OVERLAP_ID = '22222222-2222-2222-2222-222222222222' + +function patchRequest(status: 'resolved' | 'active' | 'ignored' = 'resolved') { + return new Request(`https://example.com/api/repositories/${REPO_ID}/overlaps/x`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ status }), + }) +} + +describe('PATCH /api/repositories/$id/overlaps/$overlapId', () => { + beforeEach(() => { + vi.clearAllMocks() + requireUser.mockResolvedValue({ id: 'user-1' }) + requireRepoAccess.mockResolvedValue({ id: REPO_ID }) + }) + + it('returns 404 for a non-UUID overlapId instead of a 500', async () => { + const res = await patch({ + request: patchRequest(), + params: { id: REPO_ID, overlapId: 'not-a-uuid' }, + }) + + expect(res.status).toBe(404) + // The malformed id must never reach the database. + expect(findFirst).not.toHaveBeenCalled() + }) + + it('returns 404 for a well-formed but nonexistent overlapId', async () => { + findFirst.mockResolvedValue(undefined) + + const res = await patch({ + request: patchRequest(), + params: { id: REPO_ID, overlapId: OVERLAP_ID }, + }) + + expect(res.status).toBe(404) + }) + + it('updates the overlap for a well-formed, existing overlapId', async () => { + findFirst.mockResolvedValue({ id: OVERLAP_ID, repositoryId: REPO_ID }) + + const res = await patch({ + request: patchRequest('resolved'), + params: { id: REPO_ID, overlapId: OVERLAP_ID }, + }) + + expect(res.status).toBe(200) + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ status: 'resolved', resolvedAt: expect.any(Date) }) + ) + }) +}) diff --git a/apps/web/src/routes/api/auth/github.callback.ts b/apps/web/src/routes/api/auth/github.callback.ts new file mode 100644 index 0000000..e742964 --- /dev/null +++ b/apps/web/src/routes/api/auth/github.callback.ts @@ -0,0 +1,129 @@ +import { createFileRoute } from '@tanstack/react-router' +import { db, users, userInstallations } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { githubOAuthCallbackSchema } from '@overlap/shared' +import { jwtVerify } from 'jose' +import { signSession } from '../../../lib/session' +import { readCookie, buildSessionCookie, buildClearCookie } from '../../../lib/auth' +import { syncUserInstallations } from '../../../lib/github-oauth' + +const clientId = process.env.GITHUB_CLIENT_ID +const clientSecret = process.env.GITHUB_CLIENT_SECRET +const appUrl = process.env.APP_URL || 'http://localhost:3000' + +export const Route = createFileRoute('/api/auth/github/callback')({ + server: { + handlers: { + GET: async ({ request }) => { + // If this callback came from a GitHub App installation flow (no state cookie), + // redirect through our own OAuth flow to establish CSRF protection. + // GitHub will auto-approve since the user already authorized. + const stateCookie = readCookie(request, 'oauth_state') + if (!stateCookie) { + return new Response(null, { + status: 302, + headers: { location: `${appUrl}/api/auth/github` }, + }) + } + + const url = new URL(request.url) + const { code, state } = githubOAuthCallbackSchema.parse({ + code: url.searchParams.get('code') ?? undefined, + state: url.searchParams.get('state') ?? undefined, + }) + + // Verify state (CSRF protection) - always enforced. The state cookie is + // itself a signed JWT (per spec S7), so verification checks integrity, + // not merely presence. + let stateClaim: unknown + try { + const { payload } = await jwtVerify(stateCookie, new TextEncoder().encode(process.env.SESSION_SECRET!), { + algorithms: ['HS256'], + }) + stateClaim = payload.state + } catch { + return Response.json({ error: 'Invalid OAuth state' }, { status: 400 }) + } + if (typeof stateClaim !== 'string' || stateClaim !== state) { + return Response.json({ error: 'Invalid OAuth state' }, { status: 400 }) + } + + // Exchange code for access token + const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + }), + }) + + const tokenData = (await tokenResponse.json()) as { + access_token?: string + error?: string + } + + if (!tokenData.access_token) { + return Response.json({ error: 'Failed to exchange code for token' }, { status: 400 }) + } + + // Fetch user profile + const userResponse = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + Accept: 'application/vnd.github+json', + }, + }) + + const githubUser = (await userResponse.json()) as { + id: number + login: string + email: string | null + avatar_url: string + } + + // Upsert user + const [user] = await db + .insert(users) + .values({ + githubId: githubUser.id, + username: githubUser.login, + email: githubUser.email, + avatarUrl: githubUser.avatar_url, + }) + .onConflictDoUpdate({ + target: users.githubId, + set: { + username: githubUser.login, + email: githubUser.email, + avatarUrl: githubUser.avatar_url, + updatedAt: new Date(), + }, + }) + .returning() + + const headers = new Headers() + headers.append('set-cookie', buildSessionCookie(await signSession(user.id))) + headers.append('set-cookie', buildClearCookie('oauth_state')) + + // Sync user's GitHub App installations into local DB + await syncUserInstallations(tokenData.access_token, user.id) + + // Check if user has any active installations + const userInsts = await db.query.userInstallations.findMany({ + where: eq(userInstallations.userId, user.id), + with: { installation: true }, + }) + const hasActive = userInsts.some((ui) => ui.installation.status === 'active') + + headers.set('location', hasActive ? appUrl : `${appUrl}?setup=1`) + + return new Response(null, { status: 302, headers }) + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/auth/github.ts b/apps/web/src/routes/api/auth/github.ts new file mode 100644 index 0000000..224dff3 --- /dev/null +++ b/apps/web/src/routes/api/auth/github.ts @@ -0,0 +1,49 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SignJWT } from 'jose' + +export const Route = createFileRoute('/api/auth/github')({ + server: { + handlers: { + GET: async () => { + const clientId = process.env.GITHUB_CLIENT_ID + const appUrl = process.env.APP_URL || 'http://localhost:3000' + if (!clientId) { + return new Response('OAuth not configured', { status: 500 }) + } + + const state = crypto.randomUUID() + + // The state cookie is signed, not merely present, per spec S7. + const stateToken = await new SignJWT({ state }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('10m') + .sign(new TextEncoder().encode(process.env.SESSION_SECRET!)) + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: `${appUrl}/api/auth/github/callback`, + scope: 'read:user user:email', + state, + }) + + const cookieParts = [ + `oauth_state=${stateToken}`, + 'HttpOnly', + 'SameSite=Lax', + 'Path=/', + 'Max-Age=600', + ] + if (process.env.NODE_ENV === 'production') cookieParts.push('Secure') + + return new Response(null, { + status: 302, + headers: { + location: `https://github.com/login/oauth/authorize?${params}`, + 'set-cookie': cookieParts.join('; '), + }, + }) + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/auth/logout.ts b/apps/web/src/routes/api/auth/logout.ts new file mode 100644 index 0000000..eaaffe4 --- /dev/null +++ b/apps/web/src/routes/api/auth/logout.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router' +import { buildClearCookie } from '../../../lib/auth' +import { SESSION_COOKIE_NAME } from '../../../lib/session' + +export const Route = createFileRoute('/api/auth/logout')({ + server: { + handlers: { + POST: async () => { + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { + 'content-type': 'application/json', + 'set-cookie': buildClearCookie(SESSION_COOKIE_NAME), + }, + }) + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/auth/me.ts b/apps/web/src/routes/api/auth/me.ts new file mode 100644 index 0000000..76293a7 --- /dev/null +++ b/apps/web/src/routes/api/auth/me.ts @@ -0,0 +1,29 @@ +import { createFileRoute } from '@tanstack/react-router' +import { db, userInstallations } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { requireUser } from '../../../lib/auth' + +export const Route = createFileRoute('/api/auth/me')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const user = await requireUser(request) + const insts = await db.query.userInstallations.findMany({ + where: eq(userInstallations.userId, user.id), + with: { installation: true }, + }) + return Response.json({ + user, + hasInstallations: insts.some( + (ui) => ui.installation.status === 'active' + ), + }) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/cron/cleanup-events.ts b/apps/web/src/routes/api/cron/cleanup-events.ts new file mode 100644 index 0000000..c98c5e7 --- /dev/null +++ b/apps/web/src/routes/api/cron/cleanup-events.ts @@ -0,0 +1,23 @@ +import { createFileRoute } from '@tanstack/react-router' +import { start } from 'workflow/api' +import { isAuthorizedCron } from '../../../lib/cron-auth' +import { cleanupEventsWorkflow } from '../../../workflows/maintenance' + +// Vercel Cron hits this on a schedule (see vercel.json). This route only +// authorizes and starts the durable workflow run; it does not wait for it +// to finish. +export const Route = createFileRoute('/api/cron/cleanup-events')({ + server: { + handlers: { + GET: async ({ request }) => { + if (!isAuthorizedCron(request)) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const run = await start(cleanupEventsWorkflow, []) + + return Response.json({ success: true, runId: run.runId }) + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/cron/prune-branches.ts b/apps/web/src/routes/api/cron/prune-branches.ts new file mode 100644 index 0000000..4d01eff --- /dev/null +++ b/apps/web/src/routes/api/cron/prune-branches.ts @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/react-router' +import { start } from 'workflow/api' +import { isAuthorizedCron } from '../../../lib/cron-auth' +import { pruneBranchesWorkflow } from '../../../workflows/maintenance' + +// Vercel Cron hits this on a schedule (see vercel.json). `pruneBranchesWorkflow` +// fans out one durable step per active repository (`pruneRepositoryBranches`), +// so its total runtime scales with the number of installations without any +// single step - or this route's own invocation - needing to cover all of +// them. This route only authorizes and starts the durable workflow run; it +// does not wait for it to finish. +export const Route = createFileRoute('/api/cron/prune-branches')({ + server: { + handlers: { + GET: async ({ request }) => { + if (!isAuthorizedCron(request)) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const run = await start(pruneBranchesWorkflow, []) + + return Response.json({ success: true, runId: run.runId }) + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/health.ts b/apps/web/src/routes/api/health.ts new file mode 100644 index 0000000..f70805e --- /dev/null +++ b/apps/web/src/routes/api/health.ts @@ -0,0 +1,28 @@ +import { createFileRoute } from '@tanstack/react-router' +import { db } from '@overlap/db' +import { sql } from 'drizzle-orm' + +export const Route = createFileRoute('/api/health')({ + server: { + handlers: { + GET: async () => { + let database = false + try { + await db.execute(sql`SELECT 1`) + database = true + } catch { + database = false + } + + return Response.json( + { + status: database ? 'ready' : 'not ready', + checks: { database }, + timestamp: new Date().toISOString(), + }, + { status: database ? 200 : 503 } + ) + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/push.ts b/apps/web/src/routes/api/push.ts new file mode 100644 index 0000000..78dead3 --- /dev/null +++ b/apps/web/src/routes/api/push.ts @@ -0,0 +1,101 @@ +import { createFileRoute } from '@tanstack/react-router' +import { db, pushSubscriptions } from '@overlap/db' +import { eq, and } from 'drizzle-orm' +import { requireUser } from '../../lib/auth' + +// Legitimate browser push service domains +const ALLOWED_PUSH_HOSTS = [ + 'fcm.googleapis.com', + 'updates.push.services.mozilla.com', + 'push.services.mozilla.com', + 'notify.windows.com', + 'web.push.apple.com', +] + +function isAllowedPushEndpoint(endpoint: string): boolean { + let url: URL + try { + url = new URL(endpoint) + } catch { + return false + } + + if (url.protocol !== 'https:') return false + + const hostname = url.hostname.toLowerCase() + return ALLOWED_PUSH_HOSTS.some( + (domain) => hostname === domain || hostname.endsWith('.' + domain) + ) +} + +export const Route = createFileRoute('/api/push')({ + server: { + handlers: { + // Subscribe to push notifications + POST: async ({ request }) => { + try { + const user = await requireUser(request) + const { endpoint, keys } = (await request.json()) as { + endpoint: string + keys: { p256dh: string; auth: string } + } + + if (!isAllowedPushEndpoint(endpoint)) { + return Response.json({ error: 'Invalid push endpoint' }, { status: 400 }) + } + + const MAX_SUBSCRIPTIONS_PER_USER = 20 + + const existing = await db.query.pushSubscriptions.findMany({ + where: eq(pushSubscriptions.userId, user.id), + }) + + const isKnownEndpoint = existing.some((s) => s.endpoint === endpoint) + + if (!isKnownEndpoint && existing.length >= MAX_SUBSCRIPTIONS_PER_USER) { + return Response.json({ error: 'Subscription limit reached' }, { status: 429 }) + } + + await db + .insert(pushSubscriptions) + .values({ + userId: user.id, + endpoint, + p256dh: keys.p256dh, + auth: keys.auth, + }) + .onConflictDoUpdate({ + target: [pushSubscriptions.userId, pushSubscriptions.endpoint], + set: { + p256dh: keys.p256dh, + auth: keys.auth, + }, + }) + + return Response.json({ success: true }) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + // Unsubscribe from push notifications + DELETE: async ({ request }) => { + try { + const user = await requireUser(request) + const { endpoint } = (await request.json()) as { endpoint: string } + + await db + .delete(pushSubscriptions) + .where( + and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)) + ) + + return Response.json({ success: true }) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.branches.ts b/apps/web/src/routes/api/repositories.$id.branches.ts new file mode 100644 index 0000000..d7c7271 --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.branches.ts @@ -0,0 +1,50 @@ +import { createFileRoute } from '@tanstack/react-router' +import { db, branches } from '@overlap/db' +import { eq, and, desc, sql } from 'drizzle-orm' +import { repositoryIdParamSchema, branchQuerySchema } from '@overlap/shared' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id/branches')({ + server: { + handlers: { + // List branches for a repository + GET: async ({ request, params }) => { + try { + const user = await requireUser(request) + const { id } = repositoryIdParamSchema.parse(params) + const url = new URL(request.url) + const { includeDefault, includeStale, page, limit } = branchQuerySchema.parse( + Object.fromEntries(url.searchParams) + ) + + await requireRepoAccess(user, id) + + const conditions = [eq(branches.repositoryId, id)] + + if (!includeDefault) { + conditions.push(eq(branches.isDefault, false)) + } + + if (!includeStale) { + const staleDate = new Date() + staleDate.setDate(staleDate.getDate() - 14) + conditions.push(sql`${branches.lastSeenAt} > ${staleDate.toISOString()}`) + } + + const branchList = await db.query.branches.findMany({ + where: and(...conditions), + orderBy: desc(branches.lastSeenAt), + limit, + offset: (page - 1) * limit, + }) + + return Response.json(branchList) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.diffs.ts b/apps/web/src/routes/api/repositories.$id.diffs.ts new file mode 100644 index 0000000..98bb572 --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.diffs.ts @@ -0,0 +1,82 @@ +import { createFileRoute } from '@tanstack/react-router' +import { db, repositories } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { repositoryIdParamSchema, diffQuerySchema } from '@overlap/shared' +import { getGitHubClient } from '@overlap/github' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id/diffs')({ + server: { + handlers: { + // Get all file diffs between two branches + GET: async ({ request, params }) => { + try { + const user = await requireUser(request) + const { id } = repositoryIdParamSchema.parse(params) + const url = new URL(request.url) + const { base, head } = diffQuerySchema.parse(Object.fromEntries(url.searchParams)) + + await requireRepoAccess(user, id) + + const repoWithInstallation = await db.query.repositories.findFirst({ + where: eq(repositories.id, id), + with: { installation: true }, + }) + + if (!repoWithInstallation?.installation) { + return Response.json({ error: 'Installation not found' }, { status: 500 }) + } + + const [owner, name] = repoWithInstallation.fullName.split('/') + const github = getGitHubClient() + + try { + const diffs = await github.getCompareDiffs( + repoWithInstallation.installation.installationId, + owner, + name, + base, + head + ) + + return Response.json({ files: diffs }) + } catch (error: unknown) { + // This route is not a workflow step, so it gets no retry and no + // `classifyGitHubError` (that helper returns `FatalError` / + // `RetryableError`, which only mean something to the workflow + // runtime - throwing one here would just be an unhandled + // rejection with extra steps). It has to translate GitHub's + // error shape into an HTTP response itself. Octokit's + // rate-limit and secondary-rate-limit responses both come back + // as 403 or 429; without handling them explicitly they fall + // through to `throw error` below and surface to the client as + // an unhandled 500 instead of a response it can act on. + const err = error as { + status?: number + message?: string + response?: { headers?: Record } + } + if (err.status === 404) { + return Response.json({ error: 'Branch no longer exists' }, { status: 404 }) + } + if (err.status === 403 || err.status === 429) { + const retryAfter = err.response?.headers?.['retry-after'] + return Response.json( + { error: 'GitHub rate limit exceeded, please try again shortly' }, + { + status: 429, + ...(retryAfter ? { headers: { 'Retry-After': retryAfter } } : {}), + } + ) + } + throw error + } + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts new file mode 100644 index 0000000..ca867ca --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.test-notify.ts @@ -0,0 +1,67 @@ +import { createFileRoute } from '@tanstack/react-router' +import { db, overlaps } from '@overlap/db' +import { eq, and } from 'drizzle-orm' +import { repositoryIdParamSchema, idSchema } from '@overlap/shared' +import { start } from 'workflow/api' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' +import { testNotifyWorkflow } from '../../workflows/test-notify' + +export const Route = createFileRoute('/api/repositories/$id/overlaps/$overlapId/test-notify')({ + server: { + handlers: { + // DEV ONLY: Test push notification for an existing overlap + POST: async ({ request, params }) => { + try { + if (process.env.NODE_ENV === 'production') { + return Response.json({ error: 'Not found' }, { status: 404 }) + } + + const user = await requireUser(request) + + const { id } = repositoryIdParamSchema.parse(params) + + // See repositories.$id.overlaps.$overlapId.ts: `overlapId` never + // went through a schema, so a non-UUID value reached the query + // below raw and Postgres rejected it with 22P02, which escaped + // this route's `catch (res)` block as an unhandled 500 instead of + // a 404. + const parsedOverlapId = idSchema.safeParse(params.overlapId) + if (!parsedOverlapId.success) { + return Response.json({ error: 'Overlap not found' }, { status: 404 }) + } + const overlapId = parsedOverlapId.data + + await requireRepoAccess(user, id) + + const overlap = await db.query.overlaps.findFirst({ + where: and(eq(overlaps.id, overlapId), eq(overlaps.repositoryId, id)), + with: { sourceBranch: true, targetBranch: true }, + }) + + if (!overlap) { + return Response.json({ error: 'Overlap not found' }, { status: 404 }) + } + + // The original Fastify handler queued this via BullMQ + // (fastify.queues.pushNotification.add(...)). The replacement is a + // durable run of the "sendPush" step. + const run = await start(testNotifyWorkflow, [ + id, + overlapId, + overlap.targetBranchId, + ]) + + return Response.json({ + success: true, + message: 'Test notification queued', + runId: run.runId, + }) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.ts b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.ts new file mode 100644 index 0000000..4890b6f --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.overlaps.$overlapId.ts @@ -0,0 +1,59 @@ +import { createFileRoute } from '@tanstack/react-router' +import { db, overlaps } from '@overlap/db' +import { eq, and } from 'drizzle-orm' +import { repositoryIdParamSchema, overlapUpdateSchema, idSchema } from '@overlap/shared' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id/overlaps/$overlapId')({ + server: { + handlers: { + // Update overlap status (resolve/ignore) + PATCH: async ({ request, params }) => { + try { + const user = await requireUser(request) + const { id } = repositoryIdParamSchema.parse(params) + const { status } = overlapUpdateSchema.parse(await request.json()) + + // `overlapId` never went through a schema, so a non-UUID value + // (e.g. a stray path segment) reached the query below raw and + // Postgres rejected it with 22P02 ("invalid input syntax for + // type uuid"), which escaped the `catch (res)` block below as an + // unhandled 500 instead of the 404 a bad id should produce. + // Validating up front folds that case into the same "not found" + // response an unrecognized-but-well-formed id already gets. + const parsedOverlapId = idSchema.safeParse(params.overlapId) + if (!parsedOverlapId.success) { + return Response.json({ error: 'Overlap not found' }, { status: 404 }) + } + const overlapId = parsedOverlapId.data + + await requireRepoAccess(user, id) + + const overlap = await db.query.overlaps.findFirst({ + where: and(eq(overlaps.id, overlapId), eq(overlaps.repositoryId, id)), + }) + + if (!overlap) { + return Response.json({ error: 'Overlap not found' }, { status: 404 }) + } + + const [updated] = await db + .update(overlaps) + .set({ + status, + resolvedAt: status === 'resolved' ? new Date() : null, + updatedAt: new Date(), + }) + .where(eq(overlaps.id, overlapId)) + .returning() + + return Response.json(updated) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.overlaps.ts b/apps/web/src/routes/api/repositories.$id.overlaps.ts new file mode 100644 index 0000000..9d82b3d --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.overlaps.ts @@ -0,0 +1,59 @@ +import { createFileRoute } from '@tanstack/react-router' +import { db, overlaps } from '@overlap/db' +import { eq, and, desc, sql } from 'drizzle-orm' +import { repositoryIdParamSchema, overlapQuerySchema } from '@overlap/shared' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id/overlaps')({ + server: { + handlers: { + // List overlaps for a repository + GET: async ({ request, params }) => { + try { + const user = await requireUser(request) + const { id } = repositoryIdParamSchema.parse(params) + const url = new URL(request.url) + const { status, severity, branchId, page, limit } = overlapQuerySchema.parse( + Object.fromEntries(url.searchParams) + ) + + await requireRepoAccess(user, id) + + const conditions = [eq(overlaps.repositoryId, id)] + + if (status) { + conditions.push(eq(overlaps.status, status)) + } + + if (severity) { + conditions.push(eq(overlaps.severity, severity)) + } + + if (branchId) { + conditions.push( + sql`(${overlaps.sourceBranchId} = ${branchId} OR ${overlaps.targetBranchId} = ${branchId})` + ) + } + + const overlapList = await db.query.overlaps.findMany({ + where: and(...conditions), + with: { + sourceBranch: true, + targetBranch: true, + files: true, + }, + orderBy: desc(overlaps.detectedAt), + limit, + offset: (page - 1) * limit, + }) + + return Response.json(overlapList) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.settings.ts b/apps/web/src/routes/api/repositories.$id.settings.ts new file mode 100644 index 0000000..a681e03 --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.settings.ts @@ -0,0 +1,37 @@ +import { createFileRoute } from '@tanstack/react-router' +import { db, repositorySettings } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { repositoryIdParamSchema, repositorySettingsUpdateSchema } from '@overlap/shared' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id/settings')({ + server: { + handlers: { + // Update repository settings + PATCH: async ({ request, params }) => { + try { + const user = await requireUser(request) + const { id } = repositoryIdParamSchema.parse(params) + const updates = repositorySettingsUpdateSchema.parse(await request.json()) + + await requireRepoAccess(user, id) + + const [updated] = await db + .update(repositorySettings) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(repositorySettings.repositoryId, id)) + .returning() + + return Response.json(updated) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.$id.ts b/apps/web/src/routes/api/repositories.$id.ts new file mode 100644 index 0000000..c87d71e --- /dev/null +++ b/apps/web/src/routes/api/repositories.$id.ts @@ -0,0 +1,35 @@ +import { createFileRoute } from '@tanstack/react-router' +import { db, repositories } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { repositoryIdParamSchema } from '@overlap/shared' +import { requireUser } from '../../lib/auth' +import { requireRepoAccess } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories/$id')({ + server: { + handlers: { + // Get repository by ID (scoped to user's installations) + GET: async ({ request, params }) => { + try { + const user = await requireUser(request) + const { id } = repositoryIdParamSchema.parse(params) + await requireRepoAccess(user, id) + + // Re-query with relations for the detail view + const repoWithRelations = await db.query.repositories.findFirst({ + where: eq(repositories.id, id), + with: { + settings: true, + installation: true, + }, + }) + + return Response.json(repoWithRelations) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/repositories.ts b/apps/web/src/routes/api/repositories.ts new file mode 100644 index 0000000..cb8b6c7 --- /dev/null +++ b/apps/web/src/routes/api/repositories.ts @@ -0,0 +1,66 @@ +import { createFileRoute } from '@tanstack/react-router' +import { db, repositories, branches, overlaps } from '@overlap/db' +import { eq, and, desc, count, inArray } from 'drizzle-orm' +import { requireUser } from '../../lib/auth' +import { getUserInstallationIds } from '../../lib/repo-access' + +export const Route = createFileRoute('/api/repositories')({ + server: { + handlers: { + // List repositories (scoped to user's installations) + GET: async ({ request }) => { + try { + const user = await requireUser(request) + const installationIds = await getUserInstallationIds(user.id) + + if (installationIds.length === 0) { + return Response.json([]) + } + + const repos = await db.query.repositories.findMany({ + where: and( + eq(repositories.isActive, true), + inArray(repositories.installationId, installationIds) + ), + with: { + settings: true, + }, + orderBy: desc(repositories.updatedAt), + }) + + // Get summary stats for each repo + const results = await Promise.all( + repos.map(async (repo) => { + const [branchCount] = await db + .select({ count: count() }) + .from(branches) + .where(and(eq(branches.repositoryId, repo.id), eq(branches.isDefault, false))) + + const [overlapCount] = await db + .select({ count: count() }) + .from(overlaps) + .where(and(eq(overlaps.repositoryId, repo.id), eq(overlaps.status, 'active'))) + + return { + id: repo.id, + name: repo.name, + fullName: repo.fullName, + defaultBranch: repo.defaultBranch, + isPrivate: repo.isPrivate, + activeBranches: branchCount?.count ?? 0, + activeOverlaps: overlapCount?.count ?? 0, + lastSyncedAt: repo.lastSyncedAt, + settings: repo.settings, + } + }) + ) + + return Response.json(results) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts b/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts new file mode 100644 index 0000000..bc0d799 --- /dev/null +++ b/apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts @@ -0,0 +1,257 @@ +import { createHmac } from 'node:crypto' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { handleWebhook } from '../github-handler' + +const inserts = vi.fn() +const starts = vi.fn() +const findRepository = vi.fn() +const findWebhookEvent = vi.fn() +const updates = vi.fn() + +vi.mock('@overlap/db', () => ({ + db: { + insert: () => ({ values: inserts }), + query: { + repositories: { findFirst: (...args: unknown[]) => findRepository(...args) }, + webhookEvents: { findFirst: (...args: unknown[]) => findWebhookEvent(...args) }, + }, + update: () => ({ + set: (values: unknown) => ({ + where: (condition: unknown) => updates(values, condition), + }), + }), + }, + webhookEvents: {}, + repositories: { githubId: 'githubId' }, +})) + +function sign(payload: string, secret: string): string { + return `sha256=${createHmac('sha256', secret).update(payload).digest('hex')}` +} + +beforeEach(() => { + inserts.mockReset() + starts.mockReset() + findRepository.mockReset() + findWebhookEvent.mockReset() + updates.mockReset() + process.env.GITHUB_WEBHOOK_SECRET = 'test-secret' +}) + +describe('handleWebhook', () => { + it('rejects an invalid signature with 401', async () => { + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': 'sha256=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }) + + const res = await handleWebhook(req, { start: starts }) + expect(res.status).toBe(401) + }) + + it('writes nothing to the database when the signature is invalid', async () => { + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': 'sha256=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }) + + await handleWebhook(req, { start: starts }) + expect(inserts).not.toHaveBeenCalled() + }) + + it('starts no workflow when the signature is invalid', async () => { + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': 'sha256=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }) + + await handleWebhook(req, { start: starts }) + expect(starts).not.toHaveBeenCalled() + }) + + it('accepts a valid signature, stores the event, and starts the workflow', async () => { + const body = JSON.stringify({ ref: 'refs/heads/main' }) + const signature = sign(body, 'test-secret') + + inserts.mockReturnValue({ + onConflictDoNothing: () => ({ + returning: () => + Promise.resolve([ + { id: 'row-1', deliveryId: 'abc-123', eventType: 'push', payload: {} }, + ]), + }), + }) + + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': signature, + 'content-type': 'application/json', + }, + body, + }) + + const res = await handleWebhook(req, { start: starts }) + + expect(res.status).toBe(200) + expect(inserts).toHaveBeenCalledTimes(1) + expect(starts).toHaveBeenCalledTimes(1) + expect(starts.mock.calls[0]?.[1]).toEqual(['abc-123']) + // dispatchedAt is only recorded once start() has actually returned. + expect(updates).toHaveBeenCalledTimes(1) + expect(updates.mock.calls[0]?.[0]).toMatchObject({ dispatchedAt: expect.any(Date) }) + }) + + describe('redelivery of an already-stored deliveryId (onConflictDoNothing returns no row)', () => { + function redeliveredRequest() { + const body = JSON.stringify({ ref: 'refs/heads/main' }) + const signature = sign(body, 'test-secret') + + inserts.mockReturnValue({ + onConflictDoNothing: () => ({ + returning: () => Promise.resolve([]), + }), + }) + + return new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': signature, + 'content-type': 'application/json', + }, + body, + }) + } + + it('restarts the workflow when dispatchedAt is null (no run was ever created)', async () => { + findWebhookEvent.mockResolvedValue({ + id: 'row-1', + deliveryId: 'abc-123', + dispatchedAt: null, + error: null, + processedAt: null, + }) + + const res = await handleWebhook(redeliveredRequest(), { start: starts }) + + expect(res.status).toBe(200) + expect(starts).toHaveBeenCalledTimes(1) + expect(starts.mock.calls[0]?.[1]).toEqual(['abc-123']) + expect(updates).toHaveBeenCalledTimes(1) + }) + + it('does NOT restart when a run is in flight (dispatchedAt set, no error, not yet processed)', async () => { + findWebhookEvent.mockResolvedValue({ + id: 'row-1', + deliveryId: 'abc-123', + dispatchedAt: new Date('2026-01-01T00:00:00Z'), + error: null, + processedAt: null, + }) + + const res = await handleWebhook(redeliveredRequest(), { start: starts }) + + expect(res.status).toBe(200) + expect(starts).not.toHaveBeenCalled() + expect(updates).not.toHaveBeenCalled() + }) + + it('restarts the workflow when the prior run terminally failed (error set, not processed)', async () => { + findWebhookEvent.mockResolvedValue({ + id: 'row-1', + deliveryId: 'abc-123', + dispatchedAt: new Date('2026-01-01T00:00:00Z'), + error: 'GitHub API returned 500', + processedAt: null, + }) + + const res = await handleWebhook(redeliveredRequest(), { start: starts }) + + expect(res.status).toBe(200) + expect(starts).toHaveBeenCalledTimes(1) + expect(starts.mock.calls[0]?.[1]).toEqual(['abc-123']) + expect(updates).toHaveBeenCalledTimes(1) + }) + + it('does NOT restart when the delivery already finished (processedAt set)', async () => { + findWebhookEvent.mockResolvedValue({ + id: 'row-1', + deliveryId: 'abc-123', + dispatchedAt: new Date('2026-01-01T00:00:00Z'), + error: null, + processedAt: new Date('2026-01-01T00:05:00Z'), + }) + + const res = await handleWebhook(redeliveredRequest(), { start: starts }) + + expect(res.status).toBe(200) + expect(starts).not.toHaveBeenCalled() + expect(updates).not.toHaveBeenCalled() + }) + }) + + it('rejects a validly-signed delivery with no x-github-delivery header with 400', async () => { + const body = JSON.stringify({ ref: 'refs/heads/main' }) + const signature = sign(body, 'test-secret') + + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-hub-signature-256': signature, + 'content-type': 'application/json', + }, + body, + }) + + const res = await handleWebhook(req, { start: starts }) + + expect(res.status).toBe(400) + expect(inserts).not.toHaveBeenCalled() + expect(starts).not.toHaveBeenCalled() + }) + + it('rejects a validly-signed `null` JSON body with 400 instead of throwing', async () => { + const body = 'null' + const signature = sign(body, 'test-secret') + + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': signature, + 'content-type': 'application/json', + }, + body, + }) + + const res = await handleWebhook(req, { start: starts }) + + expect(res.status).toBe(400) + expect(inserts).not.toHaveBeenCalled() + expect(starts).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/routes/api/webhooks/github-handler.ts b/apps/web/src/routes/api/webhooks/github-handler.ts new file mode 100644 index 0000000..feff58c --- /dev/null +++ b/apps/web/src/routes/api/webhooks/github-handler.ts @@ -0,0 +1,150 @@ +/** + * Ported from apps/api/src/routes/webhooks.ts. + * + * Spec S2 makes the ordering here a security requirement: HMAC verification + * of the raw request body must precede every database write and every + * workflow start. Get this wrong and the endpoint becomes a public + * unauthenticated write that inserts attacker-controlled JSON into + * `webhook_events` and starts a billed workflow run per request. + * + * `start` is an injected dependency (see `Deps`) rather than imported + * directly, which is what makes the ordering testable without a running + * workflow runtime - see `__tests__/verify-order.test.ts`. + * + * Deduplication moved off BullMQ's `jobId`, which lived in Redis and expired + * on a TTL, onto the unique constraint on `webhook_events.deliveryId`, which + * is durable in Postgres. `.onConflictDoNothing().returning()` returns no row + * when the delivery is a GitHub redelivery of a `deliveryId` already stored. + * That is not the same as "already handled" or "safe to ignore": a stored + * row can be in one of four states by the time a redelivery arrives, and + * `dispatchedAt` (set only after `deps.start` has actually returned) is what + * distinguishes them: + * + * - `dispatchedAt` null: either the process died between the insert and the + * start, or `deps.start` itself threw. No run exists. Redeliver -> start. + * - `dispatchedAt` set, `error` null, `processedAt` null: a run exists and + * is still in flight. Redeliver -> do nothing; starting a second run here + * would produce the exact duplicate-check-run / duplicate-push- + * notification bug this table's unique constraint exists to prevent. + * - `error` set, `processedAt` null: the run terminally failed (see + * `markEventProcessed` in `apps/web/src/workflows/steps.ts`, which sets + * `error` but never `processedAt` on the failure path). Redeliver -> + * start again; this is the whole point of making redelivery curative. + * - `processedAt` set: finished. Redeliver -> do nothing. + * + * The original handler also ran branch-deletion cleanup (deleting the + * `branches` / `branch_files` rows) inline, synchronously, before enqueuing. + * That logic now lives in the `upsertBranch` workflow step + * (`apps/web/src/workflows/steps.ts`) instead of here: it is a database + * write driven by webhook payload data, so it belongs in the durable, + * retryable workflow rather than in the thin, unauthenticated-until-verified + * route handler. Duplicating it here would mean running it twice per + * delivery for no benefit, and would reintroduce a database write that is + * not retried if it fails. + */ +import { verifyWebhookSignature } from '@overlap/github' +import { db, webhookEvents, repositories } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { processWebhook } from '../../../workflows/process-webhook' + +type Deps = { start: (wf: unknown, args: unknown[]) => Promise } + +export async function handleWebhook(request: Request, deps: Deps): Promise { + const secret = process.env.GITHUB_WEBHOOK_SECRET + if (!secret) { + return new Response(JSON.stringify({ error: 'Server configuration error' }), { + status: 500, + }) + } + + // Raw bytes, before any parsing. This is what GitHub signed. + const raw = await request.text() + const signature = request.headers.get('x-hub-signature-256') ?? '' + const eventType = request.headers.get('x-github-event') ?? '' + const deliveryId = request.headers.get('x-github-delivery') ?? '' + + // Nothing below this line may execute for an unverified request. + const verification = verifyWebhookSignature(raw, signature, secret) + if (!verification.valid) { + return new Response(JSON.stringify({ error: 'Invalid signature' }), { + status: 401, + }) + } + + // A delivery with no id can never be deduplicated: every future delivery + // that is also missing the header would collide with this one on the + // empty string and be silently swallowed as a "redelivery" forever. + if (!deliveryId) { + return new Response(JSON.stringify({ error: 'Missing delivery id' }), { + status: 400, + }) + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return new Response(JSON.stringify({ error: 'Invalid JSON payload' }), { + status: 400, + }) + } + + // Valid JSON can be null, a number, a string, or a boolean, none of which + // has a `.repository` property to read below. `typeof null === 'object'`, + // so it needs its own check. + if (typeof parsed !== 'object' || parsed === null) { + return new Response(JSON.stringify({ error: 'Invalid JSON payload' }), { + status: 400, + }) + } + + const payload = parsed as Record + + let repositoryId: string | null = null + const repoData = payload.repository as { id?: number } | undefined + if (repoData?.id) { + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.githubId, repoData.id), + }) + repositoryId = repo?.id ?? null + } + + const [row] = await db + .insert(webhookEvents) + .values({ eventType, deliveryId, repositoryId, payload }) + .onConflictDoNothing() + .returning() + + if (row) { + await deps.start(processWebhook, [deliveryId]) + // Only recorded once deps.start has actually returned: if it throws, + // dispatchedAt stays null and a later redelivery will retry the + // dispatch instead of finding a row that looks like it already has a + // run and skipping it forever. + await db + .update(webhookEvents) + .set({ dispatchedAt: new Date() }) + .where(eq(webhookEvents.deliveryId, deliveryId)) + return new Response(JSON.stringify({ received: true }), { status: 200 }) + } + + // No row means the unique constraint on deliveryId rejected the insert: + // this deliveryId was already stored. See the four states documented + // above; only the first two ever justify starting a workflow again. + const existing = await db.query.webhookEvents.findFirst({ + where: eq(webhookEvents.deliveryId, deliveryId), + }) + + const shouldRedispatch = + !!existing && (!existing.dispatchedAt || (!!existing.error && !existing.processedAt)) + + if (shouldRedispatch) { + await deps.start(processWebhook, [deliveryId]) + await db + .update(webhookEvents) + .set({ dispatchedAt: new Date() }) + .where(eq(webhookEvents.deliveryId, deliveryId)) + } + + return new Response(JSON.stringify({ received: true }), { status: 200 }) +} diff --git a/apps/web/src/routes/api/webhooks/github.ts b/apps/web/src/routes/api/webhooks/github.ts new file mode 100644 index 0000000..4b52006 --- /dev/null +++ b/apps/web/src/routes/api/webhooks/github.ts @@ -0,0 +1,18 @@ +import { createFileRoute } from '@tanstack/react-router' +import { start } from 'workflow/api' +import { handleWebhook } from './github-handler' + +// `start` is overloaded (it also accepts a `deploymentId` option as a third +// argument). `handleWebhook`'s injected `Deps.start` intentionally narrows +// that down to the one shape this route ever calls it with, so the ordering +// can be unit tested without importing the workflow runtime. This cast +// bridges the two: the call below always matches the real signature. +const startWorkflow = start as unknown as (wf: unknown, args: unknown[]) => Promise + +export const Route = createFileRoute('/api/webhooks/github')({ + server: { + handlers: { + POST: async ({ request }) => handleWebhook(request, { start: startWorkflow }), + }, + }, +}) diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 8919c95..a49aa7e 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -4,7 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/com import { Badge } from '~/components/ui/badge' import { Button } from '~/components/ui/button' import { Skeleton } from '~/components/ui/skeleton' -import { GitBranch, AlertTriangle, GitPullRequest, Clock, Loader2 } from 'lucide-react' +import { GitBranch, AlertTriangle, GitPullRequest } from 'lucide-react' import { ProtectedRoute } from '~/components/protected-route' import { NotificationPrompt } from '~/components/notification-prompt' import { api } from '~/lib/api' diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index d3731b9..0c23c5e 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -30,7 +30,7 @@ function LoginPage() { const handleGitHubLogin = () => { setIsRedirecting(true) - window.location.href = '/auth/github' + window.location.href = '/api/auth/github' } return ( diff --git a/apps/web/src/routes/repositories.tsx b/apps/web/src/routes/repositories.tsx index 75a67ad..b646f2d 100644 --- a/apps/web/src/routes/repositories.tsx +++ b/apps/web/src/routes/repositories.tsx @@ -4,7 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/com import { Badge } from '~/components/ui/badge' import { Button } from '~/components/ui/button' import { Skeleton } from '~/components/ui/skeleton' -import { GitBranch, Lock, Globe, ChevronRight, Loader2, AlertTriangle } from 'lucide-react' +import { GitBranch, Lock, Globe, ChevronRight, AlertTriangle } from 'lucide-react' import { ProtectedRoute } from '~/components/protected-route' import { api } from '~/lib/api' diff --git a/apps/web/src/routes/repositories_.$repoId.tsx b/apps/web/src/routes/repositories_.$repoId.tsx index 3cf7354..7452cb7 100644 --- a/apps/web/src/routes/repositories_.$repoId.tsx +++ b/apps/web/src/routes/repositories_.$repoId.tsx @@ -343,7 +343,7 @@ function OverlapCard({ overlap, repoId, defaultBranch, userGithubId, onResolve,
- {/* Inline diff panel — side by side */} + {/* Inline diff panel - side by side */} {selectedFile && (
diff --git a/apps/web/src/workflows/__tests__/errors.test.ts b/apps/web/src/workflows/__tests__/errors.test.ts new file mode 100644 index 0000000..6422586 --- /dev/null +++ b/apps/web/src/workflows/__tests__/errors.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { FatalError, RetryableError } from 'workflow' +import { classifyGitHubError } from '../errors' + +// `RetryableError` normalizes the `retryAfter` option into an absolute Date at +// construction time, so the duration string is asserted as the instant it +// resolves to. Freezing the clock keeps that instant deterministic. +const NOW = new Date('2026-01-01T00:00:00.000Z') + +describe('classifyGitHubError', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('maps 429 to RetryableError honoring Retry-After', () => { + const err = classifyGitHubError({ + status: 429, + message: 'rate limited', + response: { headers: { 'retry-after': '120' } }, + }) + expect(err).toBeInstanceOf(RetryableError) + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 120_000) + ) + }) + + it('maps 403 to RetryableError', () => { + const err = classifyGitHubError({ status: 403, message: 'forbidden' }) + expect(err).toBeInstanceOf(RetryableError) + }) + + it('defaults Retry-After to 5m when the header is absent', () => { + const err = classifyGitHubError({ status: 429, message: 'rate limited' }) + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 300_000) + ) + }) + + it('maps 500 to RetryableError', () => { + expect(classifyGitHubError({ status: 500, message: 'boom' })).toBeInstanceOf( + RetryableError + ) + }) + + it('maps 404 to FatalError', () => { + expect(classifyGitHubError({ status: 404, message: 'gone' })).toBeInstanceOf( + FatalError + ) + }) + + it('maps 422 to FatalError', () => { + expect( + classifyGitHubError({ status: 422, message: 'unprocessable' }) + ).toBeInstanceOf(FatalError) + }) + + // A Retry-After the constructor cannot parse would throw out of the + // classifier itself, stripping the 429 of its RetryableError classification. + // These cover the hostile shapes that reach `ms` as a duration string. + const rateLimited = (retryAfter: string) => + classifyGitHubError({ + status: 429, + message: 'rate limited', + response: { headers: { 'retry-after': retryAfter } }, + }) + + it('caps an oversized Retry-After at one hour rather than throwing', () => { + const err = rateLimited('1000000000000000000000') + expect(err).toBeInstanceOf(RetryableError) + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 3_600_000) + ) + }) + + it('caps a Retry-After that would overflow the Date range', () => { + const err = rateLimited('99999999999999') + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 3_600_000) + ) + }) + + it('ignores a Retry-After in exponent notation', () => { + const err = rateLimited('1e21') + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 300_000) + ) + }) + + it('ignores a hexadecimal Retry-After', () => { + const err = rateLimited('0x10') + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 300_000) + ) + }) + + it('returns a RetryableError when the thrown value is not an object', () => { + const err = classifyGitHubError(null as never) + expect(err).toBeInstanceOf(RetryableError) + expect(err.message).toBe('GitHub request failed') + expect((err as RetryableError).retryAfter).toEqual( + new Date(NOW.getTime() + 30_000) + ) + }) +}) diff --git a/apps/web/src/workflows/__tests__/mark-event-processed.test.ts b/apps/web/src/workflows/__tests__/mark-event-processed.test.ts new file mode 100644 index 0000000..602a4e9 --- /dev/null +++ b/apps/web/src/workflows/__tests__/mark-event-processed.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +/** + * `markEventProcessed` used to branch on `error ? ... : ...`, which meant an + * empty-string error message (falsy but not absent) silently took the + * success path and set `processedAt` instead of recording the error. The fix + * branches on `error !== undefined` instead. These tests pin that: a defined + * error, including an empty string, must always take the error path, and + * only a genuinely omitted error takes the success path. + * + * The success path also used to leave a stale `error` from a previous failed + * attempt in place, since it only ever set `processedAt`. A row updated by a + * successful GitHub Redeliver after an earlier failure would then show both + * `error` and `processedAt` set, misrepresenting a cured delivery as failed + * in the one operator-facing table for delivery outcomes. The success path + * now clears `error` explicitly. + */ + +const { dbMock, setSpy } = vi.hoisted(() => { + const setSpy = vi.fn() + + const dbMock = { + update: vi.fn(() => ({ + set: vi.fn((values: unknown) => { + setSpy(values) + return { + where: vi.fn(async () => undefined), + } + }), + })), + } + + return { dbMock, setSpy } +}) + +vi.mock('@overlap/db', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, db: dbMock } +}) + +const { markEventProcessed } = await import('../steps') + +const DELIVERY_ID = 'delivery-1' + +describe('markEventProcessed', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('records processedAt and clears error when no error is passed', async () => { + const result = await markEventProcessed(DELIVERY_ID) + + expect(setSpy).toHaveBeenCalledWith({ processedAt: expect.any(Date), error: null }) + expect(result).toEqual({ deliveryId: DELIVERY_ID }) + }) + + it('clears a previous failure on a successful GitHub redelivery', async () => { + // Simulates the Redeliver scenario: a delivery previously failed and + // recorded an error, an operator fixes the cause and redelivers, and the + // retry succeeds. The row must not be left claiming both outcomes. + await markEventProcessed(DELIVERY_ID, 'boom') + setSpy.mockClear() + + await markEventProcessed(DELIVERY_ID) + + expect(setSpy).toHaveBeenCalledWith({ processedAt: expect.any(Date), error: null }) + }) + + it('records the error when a non-empty error message is passed', async () => { + await markEventProcessed(DELIVERY_ID, 'boom') + + expect(setSpy).toHaveBeenCalledWith({ error: 'boom' }) + }) + + it('takes the error path, not the success path, for an empty-string error', async () => { + await markEventProcessed(DELIVERY_ID, '') + + expect(setSpy).toHaveBeenCalledWith({ error: '' }) + }) +}) diff --git a/apps/web/src/workflows/__tests__/post-check-run.test.ts b/apps/web/src/workflows/__tests__/post-check-run.test.ts new file mode 100644 index 0000000..12e4556 --- /dev/null +++ b/apps/web/src/workflows/__tests__/post-check-run.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { formatCheckRunSummary } from '@overlap/github' + +/** + * `postCheckRun` looks up the `pr_alerts` row before calling GitHub so a step + * retry (GitHub call succeeded, step failed before returning) updates the + * check run it already recorded instead of creating a duplicate. These tests + * pin that branching: an existing `checkRunId` takes the update path, and its + * absence takes the create path and records the id immediately. + * + * `db` and `getGitHubClient` are mocked because `postCheckRun` reads them + * from module-level singletons; injecting a fake GitHub client is the only + * way to observe which client method it called without a real installation + * or a live database. + */ + +const { dbMock, updateSpy, insertSpy } = vi.hoisted(() => { + const updateSpy = vi.fn() + const insertSpy = vi.fn() + + const dbMock = { + query: { + pullRequests: { findFirst: vi.fn() }, + overlaps: { findFirst: vi.fn(), findMany: vi.fn() }, + prAlerts: { findFirst: vi.fn() }, + }, + update: vi.fn(() => ({ + set: vi.fn((values: unknown) => ({ + where: vi.fn(async (condition: unknown) => { + updateSpy(values, condition) + }), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn(async (values: unknown) => { + insertSpy(values) + }), + })), + } + + return { dbMock, updateSpy, insertSpy } +}) + +vi.mock('@overlap/db', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, db: dbMock } +}) + +const { githubMock } = vi.hoisted(() => { + const githubMock = { + createCheckRun: vi.fn(), + updateCheckRun: vi.fn(), + } + return { githubMock } +}) + +vi.mock('@overlap/github', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, getGitHubClient: () => githubMock } +}) + +const { postCheckRun } = await import('../steps') + +const PULL_REQUEST_ID = 'pr-1' +const OVERLAP_ID = 'overlap-1' +const REPOSITORY_ID = 'repo-1' +const INSTALLATION_ID = 42 + +const pr = { + id: PULL_REQUEST_ID, + branch: { id: 'branch-1', sha: 'sha-123' }, + repository: { + fullName: 'acme/widgets', + installation: { installationId: INSTALLATION_ID }, + }, +} + +const branchOverlap = { + id: OVERLAP_ID, + sourceBranchId: 'branch-1', + targetBranchId: 'branch-2', + fileCount: 2, + severity: 'high' as const, + files: [{ filePath: 'a.ts' }, { filePath: 'b.ts' }], + sourceBranch: { name: 'feature-a' }, + targetBranch: { name: 'feature-b' }, +} + +const expectedSummary = formatCheckRunSummary([ + { branchName: 'feature-b', fileCount: 2, severity: 'high' }, +]) + +describe('postCheckRun', () => { + beforeEach(() => { + vi.clearAllMocks() + dbMock.query.pullRequests.findFirst.mockResolvedValue(pr) + dbMock.query.overlaps.findFirst.mockResolvedValue({ id: OVERLAP_ID }) + dbMock.query.overlaps.findMany.mockResolvedValue([branchOverlap]) + }) + + it('updates the existing check run when pr_alerts already has a checkRunId', async () => { + dbMock.query.prAlerts.findFirst.mockResolvedValue({ + id: 'alert-1', + checkRunId: 555, + }) + + const result = await postCheckRun({ + repositoryId: REPOSITORY_ID, + pullRequestId: PULL_REQUEST_ID, + overlapId: OVERLAP_ID, + }) + + expect(githubMock.updateCheckRun).toHaveBeenCalledWith( + INSTALLATION_ID, + 'acme', + 'widgets', + 555, + expectedSummary.conclusion, + expectedSummary.title, + expectedSummary.summary + ) + expect(githubMock.createCheckRun).not.toHaveBeenCalled() + expect(updateSpy).not.toHaveBeenCalled() + expect(insertSpy).not.toHaveBeenCalled() + expect(result).toEqual({ checkRunId: 555 }) + }) + + it('creates a check run and records it immediately when none has been recorded', async () => { + dbMock.query.prAlerts.findFirst.mockResolvedValue(undefined) + githubMock.createCheckRun.mockResolvedValue(999) + + const result = await postCheckRun({ + repositoryId: REPOSITORY_ID, + pullRequestId: PULL_REQUEST_ID, + overlapId: OVERLAP_ID, + }) + + expect(githubMock.createCheckRun).toHaveBeenCalledWith( + INSTALLATION_ID, + 'acme', + 'widgets', + 'sha-123', + 'Overlap Detection', + expectedSummary.conclusion, + expectedSummary.title, + expectedSummary.summary + ) + expect(githubMock.updateCheckRun).not.toHaveBeenCalled() + expect(insertSpy).toHaveBeenCalledWith({ + pullRequestId: PULL_REQUEST_ID, + overlapId: OVERLAP_ID, + alertType: 'check_run', + checkRunId: 999, + }) + expect(updateSpy).not.toHaveBeenCalled() + expect(result).toEqual({ checkRunId: 999 }) + }) + + it('creates a check run and updates the stale pr_alerts row when one exists without a checkRunId', async () => { + dbMock.query.prAlerts.findFirst.mockResolvedValue({ + id: 'alert-2', + checkRunId: null, + }) + githubMock.createCheckRun.mockResolvedValue(777) + + const result = await postCheckRun({ + repositoryId: REPOSITORY_ID, + pullRequestId: PULL_REQUEST_ID, + overlapId: OVERLAP_ID, + }) + + expect(githubMock.createCheckRun).toHaveBeenCalled() + expect(githubMock.updateCheckRun).not.toHaveBeenCalled() + expect(updateSpy).toHaveBeenCalledWith( + { checkRunId: 777 }, + expect.anything() + ) + expect(insertSpy).not.toHaveBeenCalled() + expect(result).toEqual({ checkRunId: 777 }) + }) +}) diff --git a/apps/web/src/workflows/__tests__/prune-repository-branches.test.ts b/apps/web/src/workflows/__tests__/prune-repository-branches.test.ts new file mode 100644 index 0000000..348c936 --- /dev/null +++ b/apps/web/src/workflows/__tests__/prune-repository-branches.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { PgDialect } from 'drizzle-orm/pg-core' +import { and, eq, lt, type SQL } from 'drizzle-orm' + +/** + * `pruneStaleBranches` used to be a single `'use step'` function with a + * `for` loop over every active repository inside it. A step is one function + * invocation, so that loop's total runtime scaled with the number of + * installations within one invocation's duration budget - the opposite of + * what a durable workflow is for, and contrary to the comment in + * `prune-branches.ts` claiming the work "must not be bounded by a single + * function invocation". + * + * The fix splits it the same way `processWebhook` already splits the + * installation fan-out for `syncRepository`: `getActiveRepositoryIds` + * returns the id list as one step, and `pruneRepositoryBranches` prunes + * exactly one repository per step invocation. `pruneBranchesWorkflow` (in + * `../maintenance.ts`) does the looping. These tests pin the two steps in + * isolation; the pruning logic itself is unchanged from the old loop body. + * + * The scoping tests below convert the captured `where` predicate to real SQL + * text + params via `PgDialect#sqlToQuery` instead of asserting + * `expect.anything()`. A predicate is a plain object, so `expect.anything()` + * passes for it exactly as readily as for any other value - it would still + * pass if the `eq(branches.repositoryId, repo.id)` term were deleted from + * `staleBranches`'s query in steps.ts, which would make the step prune every + * stale branch fleet-wide on every invocation. Rendering to SQL and + * comparing against an independently-built expected predicate pins the + * actual scoping instead of merely pinning "a where clause was passed". + */ + +const dialect = new PgDialect() + +function renderPredicate(predicate: unknown) { + return dialect.sqlToQuery(predicate as SQL) +} + +const { dbMock, deleteSpy, updateOverlapsSpy } = vi.hoisted(() => { + const deleteSpy = vi.fn() + const updateOverlapsSpy = vi.fn() + + const dbMock = { + query: { + repositories: { findFirst: vi.fn(), findMany: vi.fn() }, + branches: { findMany: vi.fn() }, + }, + delete: vi.fn((table: unknown) => ({ + where: vi.fn(async (condition: unknown) => { + deleteSpy(table, condition) + }), + })), + update: vi.fn(() => ({ + set: vi.fn((values: unknown) => ({ + where: vi.fn(async (condition: unknown) => { + updateOverlapsSpy(values, condition) + }), + })), + })), + } + + return { dbMock, deleteSpy, updateOverlapsSpy } +}) + +vi.mock('@overlap/db', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, db: dbMock } +}) + +const { branches, branchFiles } = await import('@overlap/db') +const { DEFAULT_SETTINGS } = await import('@overlap/shared') +const { getActiveRepositoryIds, pruneRepositoryBranches } = await import('../steps') + +const REPO_ID = 'repo-uuid' +const STALE_BRANCH_ID = 'branch-uuid' + +/** Fixed instant the "freeze time" tests below run at. */ +const NOW = new Date('2026-08-12T12:00:00.000Z') + +function staleDateFor(pruningDays: number): Date { + const staleDate = new Date(NOW) + staleDate.setDate(staleDate.getDate() - pruningDays) + return staleDate +} + +describe('getActiveRepositoryIds', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the ids of active repositories only, filtered in the query', async () => { + dbMock.query.repositories.findMany.mockResolvedValue([ + { id: 'repo-1' }, + { id: 'repo-2' }, + ]) + + const result = await getActiveRepositoryIds() + + expect(result).toEqual({ repositoryIds: ['repo-1', 'repo-2'] }) + expect(dbMock.query.repositories.findMany).toHaveBeenCalledTimes(1) + }) + + it('returns an empty list when there are no active repositories', async () => { + dbMock.query.repositories.findMany.mockResolvedValue([]) + + const result = await getActiveRepositoryIds() + + expect(result).toEqual({ repositoryIds: [] }) + }) +}) + +describe('pruneRepositoryBranches', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('prunes exactly the one repository it was called with, and nothing else', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + + const OTHER_REPO_ID = 'other-repo-uuid' + + dbMock.query.repositories.findFirst.mockResolvedValue({ + id: REPO_ID, + fullName: 'acme/widgets', + settings: { pruningDays: 30 }, + }) + dbMock.query.branches.findMany.mockResolvedValue([ + { id: STALE_BRANCH_ID, repositoryId: REPO_ID, isDefault: false }, + ]) + + const result = await pruneRepositoryBranches(REPO_ID) + + expect(result).toEqual({ prunedBranches: 1 }) + expect(dbMock.query.repositories.findFirst).toHaveBeenCalledTimes(1) + expect(deleteSpy).toHaveBeenCalledWith(branchFiles, expect.anything()) + expect(deleteSpy).toHaveBeenCalledWith(branches, expect.anything()) + expect(updateOverlapsSpy).toHaveBeenCalledTimes(1) + expect(updateOverlapsSpy.mock.calls[0]?.[0]).toMatchObject({ status: 'resolved' }) + + // Pin the actual scoping of the `staleBranches` query, not merely that + // *some* where clause was passed. Rendered to real SQL + params, this + // fails if `eq(branches.repositoryId, repo.id)` is ever dropped from + // steps.ts - the query would then scope only by isDefault/lastSeenAt and + // prune every stale branch fleet-wide, exactly the regression this test + // exists to catch. + const findManyCall = dbMock.query.branches.findMany.mock.calls[0]?.[0] + const actual = renderPredicate(findManyCall.where) + const expected = renderPredicate( + and( + eq(branches.repositoryId, REPO_ID), + eq(branches.isDefault, false), + lt(branches.lastSeenAt, staleDateFor(30)) + ) + ) + expect(actual).toEqual(expected) + // Belt and suspenders on the params directly: REPO_ID must appear, and a + // scope-less query naming a *different* repository must not match. + expect(actual.params).toContain(REPO_ID) + expect(actual.params).not.toContain(OTHER_REPO_ID) + }) + + it('returns zero and touches nothing when the repository has no stale branches', async () => { + dbMock.query.repositories.findFirst.mockResolvedValue({ + id: REPO_ID, + fullName: 'acme/widgets', + settings: { pruningDays: 30 }, + }) + dbMock.query.branches.findMany.mockResolvedValue([]) + + const result = await pruneRepositoryBranches(REPO_ID) + + expect(result).toEqual({ prunedBranches: 0 }) + expect(deleteSpy).not.toHaveBeenCalled() + expect(updateOverlapsSpy).not.toHaveBeenCalled() + }) + + it('returns zero without querying branches when the repository no longer exists', async () => { + dbMock.query.repositories.findFirst.mockResolvedValue(undefined) + + const result = await pruneRepositoryBranches(REPO_ID) + + expect(result).toEqual({ prunedBranches: 0 }) + expect(dbMock.query.branches.findMany).not.toHaveBeenCalled() + expect(deleteSpy).not.toHaveBeenCalled() + }) + + it('falls back to the default pruning window when the repository has no settings row', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + + dbMock.query.repositories.findFirst.mockResolvedValue({ + id: REPO_ID, + fullName: 'acme/widgets', + settings: null, + }) + dbMock.query.branches.findMany.mockResolvedValue([]) + + await pruneRepositoryBranches(REPO_ID) + + expect(dbMock.query.branches.findMany).toHaveBeenCalledTimes(1) + + // `toHaveBeenCalledTimes(1)` alone is true for any window value - it + // does not pin what the fallback actually computes. With time frozen, + // the computed `staleDate` in the captured predicate must match + // DEFAULT_SETTINGS.PRUNING_DAYS exactly, so changing the `?? DEFAULT_ + // SETTINGS.PRUNING_DAYS` fallback (or its value) fails this test. + const findManyCall = dbMock.query.branches.findMany.mock.calls[0]?.[0] + const actual = renderPredicate(findManyCall.where) + const expected = renderPredicate( + and( + eq(branches.repositoryId, REPO_ID), + eq(branches.isDefault, false), + lt(branches.lastSeenAt, staleDateFor(DEFAULT_SETTINGS.PRUNING_DAYS)) + ) + ) + expect(actual).toEqual(expected) + }) +}) diff --git a/apps/web/src/workflows/__tests__/sync-repository.test.ts b/apps/web/src/workflows/__tests__/sync-repository.test.ts new file mode 100644 index 0000000..4f5f444 --- /dev/null +++ b/apps/web/src/workflows/__tests__/sync-repository.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +/** + * `syncRepository`'s bulk branch upsert used to be one `INSERT ... ON + * CONFLICT` statement over every remote branch. The Postgres wire protocol + * encodes the Bind message's parameter count as an int16, so a single + * statement breaks once it needs more than 65535 bound parameters - each row + * binds 5 columns plus `updatedAt` again in the conflict `set`, i.e. + * `5n + 1` parameters for n rows, a hard ceiling at n = 13106. Past that, the + * statement fails deterministically on every attempt and the repository + * never finishes syncing (see the doc comment on `syncRepository` in + * `../steps.ts`). + * + * The fix chunks the insert at `BRANCH_INSERT_CHUNK_SIZE` (5000) rows. These + * tests pin that a remote branch list larger than one chunk results in more + * than one insert call, and that the chunked writes cover exactly the same + * rows a single unchunked call would have. Row counts here stay well under + * 13,000 - large enough to force more than one chunk, small enough to run + * instantly, since the database itself is mocked and never sees real SQL. + */ + +const { dbMock, insertSpy, updateSpy } = vi.hoisted(() => { + const insertSpy = vi.fn() + const updateSpy = vi.fn() + + const dbMock = { + query: { + repositories: { findFirst: vi.fn() }, + branches: { findMany: vi.fn() }, + }, + insert: vi.fn((table: unknown) => ({ + values: vi.fn((rows: unknown) => ({ + onConflictDoUpdate: vi.fn(async (opts: unknown) => { + insertSpy(table, rows, opts) + }), + })), + })), + update: vi.fn((table: unknown) => ({ + set: vi.fn((values: unknown) => ({ + where: vi.fn(async (condition: unknown) => { + updateSpy(table, values, condition) + }), + })), + })), + } + + return { dbMock, insertSpy, updateSpy } +}) + +vi.mock('@overlap/db', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, db: dbMock } +}) + +const { githubMock } = vi.hoisted(() => { + const githubMock = { + getBranches: vi.fn(), + } + return { githubMock } +}) + +vi.mock('@overlap/github', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, getGitHubClient: () => githubMock } +}) + +const { branches, repositories } = await import('@overlap/db') +const { syncRepository, BRANCH_INSERT_CHUNK_SIZE } = await import('../steps') + +const REPOSITORY_ID = 'repo-uuid' +const INSTALLATION_ID = 42 + +const repo = { + id: REPOSITORY_ID, + fullName: 'acme/widgets', + defaultBranch: 'main', + installation: { installationId: INSTALLATION_ID }, +} + +/** Builds `count` distinct fake remote branches, cheap to generate. */ +function fakeRemoteBranches(count: number) { + return Array.from({ length: count }, (_, i) => ({ + name: `branch-${i}`, + sha: `sha-${i}`, + })) +} + +describe('syncRepository (bulk insert chunking)', () => { + beforeEach(() => { + vi.clearAllMocks() + dbMock.query.repositories.findFirst.mockResolvedValue(repo) + dbMock.query.branches.findMany.mockResolvedValue([]) + }) + + it('issues a single insert call when the remote branch list fits in one chunk', async () => { + const remoteBranches = fakeRemoteBranches(BRANCH_INSERT_CHUNK_SIZE) + githubMock.getBranches.mockResolvedValue(remoteBranches) + + await syncRepository(REPOSITORY_ID) + + const branchInsertCalls = insertSpy.mock.calls.filter(([table]) => table === branches) + expect(branchInsertCalls).toHaveLength(1) + expect((branchInsertCalls[0]?.[1] as unknown[]).length).toBe(BRANCH_INSERT_CHUNK_SIZE) + }) + + it('splits a remote branch list larger than one chunk across more than one insert call', async () => { + const totalBranches = BRANCH_INSERT_CHUNK_SIZE + 1001 + const remoteBranches = fakeRemoteBranches(totalBranches) + githubMock.getBranches.mockResolvedValue(remoteBranches) + + const result = await syncRepository(REPOSITORY_ID) + + const branchInsertCalls = insertSpy.mock.calls.filter(([table]) => table === branches) + expect(branchInsertCalls.length).toBeGreaterThan(1) + expect(branchInsertCalls).toHaveLength(2) + expect((branchInsertCalls[0]?.[1] as unknown[]).length).toBe(BRANCH_INSERT_CHUNK_SIZE) + expect((branchInsertCalls[1]?.[1] as unknown[]).length).toBe(1001) + + // The rows written across chunks must cover exactly the same set a + // single unchunked call would have written - chunking must not drop, + // duplicate, or reorder-corrupt any row. + const writtenNames = branchInsertCalls + .flatMap(([, rows]) => rows as Array<{ name: string }>) + .map((row) => row.name) + .sort() + const expectedNames = remoteBranches.map((b) => b.name).sort() + expect(writtenNames).toEqual(expectedNames) + + // added/updated counts are computed from the full remote list regardless + // of chunking, so they must reflect every branch, not just one chunk's. + expect(result.added).toBe(totalBranches) + + // The repository's lastSyncedAt update still runs exactly once after + // chunking, not once per chunk. + const repoUpdateCalls = updateSpy.mock.calls.filter(([table]) => table === repositories) + expect(repoUpdateCalls).toHaveLength(1) + }) + + it('does not insert at all when there are no remote branches', async () => { + githubMock.getBranches.mockResolvedValue([]) + + await syncRepository(REPOSITORY_ID) + + const branchInsertCalls = insertSpy.mock.calls.filter(([table]) => table === branches) + expect(branchInsertCalls).toHaveLength(0) + }) +}) diff --git a/apps/web/src/workflows/__tests__/upsert-branch.test.ts b/apps/web/src/workflows/__tests__/upsert-branch.test.ts new file mode 100644 index 0000000..e2cd297 --- /dev/null +++ b/apps/web/src/workflows/__tests__/upsert-branch.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +/** + * `upsertBranch` retires a branch when the push that triggered it deleted + * the branch on GitHub: it deletes the branch's `branch_files` rows, resolves + * any `overlaps` row naming the branch on either side, then deletes the + * `branches` row itself, and returns null (there is nothing left to sync). + * + * This mirrors the cleanup the old Fastify route + * (apps/api/src/routes/webhooks.ts) ran inline in the request handler. That + * code is gone; this step is now the only place a deletion is acted on, so + * these tests exist to pin that the cleanup actually runs rather than being + * silently skipped (see task-11-report.md, Finding 1). + */ + +const { dbMock, deleteSpy, updateOverlapsSpy } = vi.hoisted(() => { + const deleteSpy = vi.fn() + const updateOverlapsSpy = vi.fn() + + const dbMock = { + query: { + webhookEvents: { findFirst: vi.fn() }, + repositories: { findFirst: vi.fn() }, + branches: { findFirst: vi.fn() }, + }, + delete: vi.fn((table: unknown) => ({ + where: vi.fn(async (condition: unknown) => { + deleteSpy(table, condition) + }), + })), + update: vi.fn(() => ({ + set: vi.fn((values: unknown) => ({ + where: vi.fn(async (condition: unknown) => { + updateOverlapsSpy(values, condition) + }), + })), + })), + } + + return { dbMock, deleteSpy, updateOverlapsSpy } +}) + +vi.mock('@overlap/db', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, db: dbMock } +}) + +const { branches, branchFiles } = await import('@overlap/db') +const { upsertBranch } = await import('../steps') + +const DELIVERY_ID = 'delivery-1' +const BRANCH_NAME = 'feature-x' +const REPO_ID = 'repo-uuid' +const BRANCH_ID = 'branch-uuid' + +const deletionPushPayload = { + ref: `refs/heads/${BRANCH_NAME}`, + before: 'abc123', + // The all-zero SHA is GitHub's signal for "this ref was deleted". + after: '0000000000000000000000000000000000000000', + repository: { + id: 123, + name: 'widgets', + full_name: 'acme/widgets', + default_branch: 'main', + private: false, + }, + sender: { id: 1, login: 'octocat' }, + installation: { id: 999 }, + commits: [], +} + +describe('upsertBranch (branch deletion)', () => { + beforeEach(() => { + vi.clearAllMocks() + dbMock.query.webhookEvents.findFirst.mockResolvedValue({ + id: 'evt-1', + eventType: 'push', + payload: deletionPushPayload, + }) + dbMock.query.repositories.findFirst.mockResolvedValue({ + id: REPO_ID, + fullName: 'acme/widgets', + }) + dbMock.query.branches.findFirst.mockResolvedValue({ + id: BRANCH_ID, + repositoryId: REPO_ID, + name: BRANCH_NAME, + }) + }) + + it('deletes the branch_files and branches rows and resolves affected overlaps', async () => { + const result = await upsertBranch(DELIVERY_ID) + + expect(result).toBeNull() + expect(deleteSpy).toHaveBeenCalledWith(branchFiles, expect.anything()) + expect(deleteSpy).toHaveBeenCalledWith(branches, expect.anything()) + expect(updateOverlapsSpy).toHaveBeenCalledTimes(1) + expect(updateOverlapsSpy.mock.calls[0]?.[0]).toMatchObject({ status: 'resolved' }) + }) + + it('resolves overlaps before deleting the branch row, so the branch still exists for the update', async () => { + await upsertBranch(DELIVERY_ID) + + const branchDeleteCallIndex = deleteSpy.mock.calls.findIndex( + ([table]) => table === branches + ) + expect(branchDeleteCallIndex).toBeGreaterThanOrEqual(0) + expect(updateOverlapsSpy).toHaveBeenCalledTimes(1) + // update() was called (mocked above) strictly before the branches delete + // in source order; vi.fn call ordering across two different mocks can't + // be compared directly, so this is asserted structurally instead: both + // happened, and only once each. + expect(deleteSpy.mock.calls.filter(([table]) => table === branchFiles)).toHaveLength(1) + expect(deleteSpy.mock.calls.filter(([table]) => table === branches)).toHaveLength(1) + }) + + it('does nothing when the deleted branch is not found locally', async () => { + dbMock.query.branches.findFirst.mockResolvedValue(undefined) + + const result = await upsertBranch(DELIVERY_ID) + + expect(result).toBeNull() + expect(deleteSpy).not.toHaveBeenCalled() + expect(updateOverlapsSpy).not.toHaveBeenCalled() + }) + + it('does nothing when the repository is not found locally', async () => { + dbMock.query.repositories.findFirst.mockResolvedValue(undefined) + + const result = await upsertBranch(DELIVERY_ID) + + expect(result).toBeNull() + expect(dbMock.query.branches.findFirst).not.toHaveBeenCalled() + expect(deleteSpy).not.toHaveBeenCalled() + expect(updateOverlapsSpy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/workflows/errors.ts b/apps/web/src/workflows/errors.ts new file mode 100644 index 0000000..38dac6f --- /dev/null +++ b/apps/web/src/workflows/errors.ts @@ -0,0 +1,68 @@ +import { FatalError, RetryableError } from 'workflow' + +type GitHubErrorLike = { + status?: number + message?: string + response?: { headers?: Record } +} + +/** Longest backoff we will honor from a Retry-After header. */ +const MAX_RETRY_AFTER_SECONDS = 3600 + +/** + * Resolves the Retry-After header into a duration string that is always safe + * to hand to `RetryableError`. + * + * `RetryableError` throws when `ms` cannot parse the duration, so a value that + * reaches the constructor unvalidated turns a rate limit into a crash inside + * the very catch block meant to handle it. Two rules keep that from happening: + * + * - Only a bare run of digits is accepted. RFC 9110 also permits an HTTP-date, + * and `Number()` additionally accepts hex, exponent, signed and fractional + * forms - none of which are a seconds count, and several of which stringify + * back into something `ms` rejects (`1e21` becomes `"1e+21s"`). + * - The value is capped at one hour. Digits alone can still exceed the Date + * range (anything past ~8.64e12 seconds yields an Invalid Date) or park a + * step for centuries. An hour comfortably covers a GitHub primary rate + * limit reset, which is the longest wait this backoff legitimately needs. + * + * Anything outside those rules falls back to the 5m default. + */ +function retryAfterFromHeader(header: string | undefined): `${number}s` | '5m' { + if (header === undefined) { + return '5m' + } + + const trimmed = header.trim() + if (!/^\d+$/.test(trimmed)) { + return '5m' + } + + const seconds = Math.min(Number(trimmed), MAX_RETRY_AFTER_SECONDS) + return `${seconds}s` +} + +export function classifyGitHubError(err: GitHubErrorLike): Error { + // Callers funnel everything through here with `error as never`, so anything + // thrown anywhere in the call stack arrives - including null and primitives. + if (typeof err !== 'object' || err === null) { + return new RetryableError('GitHub request failed', { retryAfter: '30s' }) + } + + const status = err.status + const message = err.message ?? 'GitHub request failed' + + if (status === 429 || status === 403) { + const retryAfter = retryAfterFromHeader( + err.response?.headers?.['retry-after'] + ) + return new RetryableError(`GitHub rate limited: ${message}`, { retryAfter }) + } + + if (status !== undefined && status >= 400 && status < 500) { + return new FatalError(message) + } + + // 5xx, network failures and unknown shapes are transient. + return new RetryableError(message, { retryAfter: '30s' }) +} diff --git a/apps/web/src/workflows/maintenance.ts b/apps/web/src/workflows/maintenance.ts new file mode 100644 index 0000000..b5cdc18 --- /dev/null +++ b/apps/web/src/workflows/maintenance.ts @@ -0,0 +1,49 @@ +/** + * Maintenance workflows. + * + * These replace the repeatable BullMQ maintenance jobs. Each one is a thin + * durable wrapper around a single step so it can be triggered from a Vercel + * cron route and observed as a run. + */ + +import { + cleanupOldEvents, + getActiveRepositoryIds, + pruneRepositoryBranches, +} from './steps' + +/** + * Deletes branches nobody has pushed to inside each repository's pruning + * window. + * + * Each repository is its own durable step (`pruneRepositoryBranches`), fanned + * out over from the id list `getActiveRepositoryIds` returns - the same + * pattern `processWebhook` uses for `syncRepository`. No single step + * processes more than one repository, so this workflow's total runtime + * scales with the number of installations without any one function + * invocation needing to cover all of them. + */ +export async function pruneBranchesWorkflow(): Promise<{ + prunedBranches: number +}> { + 'use workflow' + + const { repositoryIds } = await getActiveRepositoryIds() + + let prunedBranches = 0 + for (const repositoryId of repositoryIds) { + const result = await pruneRepositoryBranches(repositoryId) + prunedBranches += result.prunedBranches + } + + return { prunedBranches } +} + +/** + * Drops webhook event rows older than seven days. + */ +export async function cleanupEventsWorkflow(): Promise<{ cleaned: boolean }> { + 'use workflow' + + return await cleanupOldEvents() +} diff --git a/apps/web/src/workflows/process-webhook.ts b/apps/web/src/workflows/process-webhook.ts new file mode 100644 index 0000000..efe8d00 --- /dev/null +++ b/apps/web/src/workflows/process-webhook.ts @@ -0,0 +1,153 @@ +/** + * The webhook workflow. + * + * This file is the reason for the migration. In the BullMQ system a push + * delivery enqueued a branch-sync job and, separately, an overlap-detection + * job with `delay: 5000` and the comment "Small delay to ensure sync completes + * first". That was a hope, not an ordering guarantee: whenever the sync took + * longer than five seconds, detection read a stale file index and computed the + * wrong overlaps. + * + * `await syncBranchFiles(...)` followed by `await detectOverlaps(...)` below is + * a real happens-before edge recorded in the workflow's event log. There is no + * delay, no sleep, and no timing assumption anywhere in this file, and there + * must never be one. + * + * Only orchestration lives here: this function runs in the workflow sandbox + * with no Node.js access. Every piece of I/O is inside a step in `./steps`. + * The orchestration is deliberately written inline rather than split into + * helper functions, so the compiler can see every step call and emit an + * accurate workflow graph. + */ + +import { + detectOverlaps, + loadEvent, + markEventProcessed, + postCheckRun, + sendPush, + syncBranchFiles, + syncInstallation, + syncRepository, + upsertBranch, + upsertPullRequest, +} from './steps' +import type { NotificationTarget } from './steps' + +/** + * Processes one stored webhook delivery. + * + * The BullMQ processor recorded the outcome of a delivery in its own + * try/catch, so the bookkeeping was structural. Here it is the workflow + * author's job: `markEventProcessed` has to run on both paths, or every + * `webhook_events` row stays unprocessed forever and failures leave no trace. + * The failure path records the error and rethrows so the run itself still + * fails. + */ +export async function processWebhook( + deliveryId: string +): Promise<{ handled: boolean }> { + 'use workflow' + + try { + const event = await loadEvent(deliveryId) + + let handled = false + let notifications: NotificationTarget[] = [] + + if (event.type === 'push') { + const branch = await upsertBranch(deliveryId) + + // Null means there is nothing to sync: a non-branch ref, a branch + // deletion, or an unknown repository. + if (branch) { + // The happens-before edge. Detection reads the file index this call + // writes, so it is awaited, never raced against a timer. + await syncBranchFiles({ + repositoryId: branch.repositoryId, + branchName: branch.branchName, + sha: branch.sha, + installationId: branch.installationId, + }) + + // The default branch is the comparison baseline; it never overlaps. + if (!branch.isDefault) { + const result = await detectOverlaps({ + repositoryId: branch.repositoryId, + branchId: branch.branchId, + }) + notifications = result.notifications + } + + handled = true + } + } else if (event.type === 'pull_request') { + const pullRequest = await upsertPullRequest(deliveryId) + + // Null means an ignored action, an unknown repository or branch, or a + // closed pull request. + if (pullRequest) { + // No branch sync here: a pull_request delivery does not change the + // head commit, so the index the preceding push wrote is current. + const result = await detectOverlaps({ + repositoryId: pullRequest.repositoryId, + branchId: pullRequest.branchId, + }) + notifications = result.notifications + + handled = true + } + } else if (event.type === 'installation') { + const { repositoryIds } = await syncInstallation(deliveryId) + + for (const repositoryId of repositoryIds) { + await syncRepository(repositoryId) + } + + handled = true + } + + // `detectOverlaps` builds `pullRequestIds` from one query - the open pull + // requests of the branch that was pushed - so every notification carries + // an identical array. Walking notifications and then pull request ids + // would issue N*M `postCheckRun` calls for N overlaps and M open pull + // requests, and each call does the same work: `postCheckRun` already + // reports *all* active overlaps for the branch in a single check run. + // Posting once per pull request produces the same GitHub state for a + // fraction of the API calls. A pull request keeps the overlap id of the + // first notification that named it, which stays correct even if the id + // lists ever stop being identical. + const postedPullRequestIds: string[] = [] + + for (const notification of notifications) { + for (const pullRequestId of notification.pullRequestIds) { + if (postedPullRequestIds.includes(pullRequestId)) continue + postedPullRequestIds.push(pullRequestId) + + await postCheckRun({ + repositoryId: notification.repositoryId, + pullRequestId, + overlapId: notification.overlapId, + }) + } + } + + // The push notification is per overlap, not per pull request: it goes to + // the developer on the other branch. + for (const notification of notifications) { + await sendPush({ + repositoryId: notification.repositoryId, + overlapId: notification.overlapId, + targetBranchId: notification.targetBranchId, + }) + } + + await markEventProcessed(deliveryId) + + return { handled } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await markEventProcessed(deliveryId, message) + throw error + } +} diff --git a/apps/web/src/workflows/steps.ts b/apps/web/src/workflows/steps.ts new file mode 100644 index 0000000..6320d4b --- /dev/null +++ b/apps/web/src/workflows/steps.ts @@ -0,0 +1,1544 @@ +/** + * Workflow steps. + * + * These are ports of the six BullMQ processors that used to live in + * `apps/worker/src/processors/`. Three things changed in the port: + * + * 1. The `Job` parameter became plain arguments. + * 2. Every `Queue` / `Redis` / `queue.add(...)` reference is gone. Steps no + * longer chain each other; a workflow function sequences them. Where a + * processor used to enqueue follow-on work it now returns the data the + * workflow needs to do the sequencing itself. + * 3. GitHub failures are classified into FatalError / RetryableError instead + * of being swallowed. + * + * Steps run in full Node.js. Only the workflow function is sandboxed. + */ + +import { + branchFiles, + branches, + db, + githubAppInstallations, + organizations, + overlapFiles, + overlaps, + prAlerts, + pullRequests, + pushSubscriptions, + repositories, + userInstallations, + users, + webhookEvents, +} from '@overlap/db' +import { and, eq, gt, inArray, lt, ne, or, sql } from 'drizzle-orm' +import { + DEFAULT_SETTINGS, + GITHUB_EVENTS, + PR_ACTIONS, + calculateSeverity, + installationEventSchema, + pullRequestEventSchema, + pushEventSchema, +} from '@overlap/shared' +import { + extractBranchFromRef, + formatCheckRunSummary, + getGitHubClient, + isBranchDeletion, + type CommitFile, +} from '@overlap/github' +import { FatalError } from 'workflow' +import { minimatch } from 'minimatch' +import webpush from 'web-push' +import { classifyGitHubError } from './errors' + +// ============================================================================ +// Types +// ============================================================================ + +/** + * A webhook delivery resolved into the shape the workflow branches on. + * `installation_repositories` deliveries are reported as `installation` + * because `syncInstallation` handles both. + */ +export type LoadedEvent = + | { + type: 'push' + deliveryId: string + eventType: string + /** Branch name extracted from the ref, or null for non-branch refs. */ + branchName: string | null + sha: string + /** True when the push deleted the branch. */ + isDeletion: boolean + } + | { + type: 'pull_request' + deliveryId: string + eventType: string + action: string + /** False for actions the pipeline ignores. */ + isRelevant: boolean + } + | { + type: 'installation' + deliveryId: string + eventType: string + action: string + } + | { + type: 'unhandled' + deliveryId: string + eventType: string + } + +/** + * Work that `detectOverlaps` used to enqueue and now hands back to the + * workflow: one entry per overlap that warrants a notification. + */ +export type NotificationTarget = { + repositoryId: string + overlapId: string + targetBranchId: string + pullRequestIds: string[] +} + +type Severity = 'low' | 'medium' | 'high' | 'critical' + +// ============================================================================ +// Internal helpers (plain functions, called from inside steps) +// ============================================================================ + +async function loadEventRow(deliveryId: string) { + const event = await db.query.webhookEvents.findFirst({ + where: eq(webhookEvents.deliveryId, deliveryId), + }) + + if (!event) { + throw new FatalError(`Webhook event not found: ${deliveryId}`) + } + + return event +} + +/** + * A payload that does not match its schema will never match it on a retry, + * so parse failures are fatal. + */ +function parsePayload( + schema: { parse: (value: unknown) => T }, + payload: unknown, + deliveryId: string +): T { + try { + return schema.parse(payload) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new FatalError( + `Malformed webhook payload for delivery ${deliveryId}: ${message}` + ) + } +} + +function mapChangeType( + status: string +): 'added' | 'modified' | 'deleted' | 'renamed' { + switch (status) { + case 'added': + return 'added' + case 'removed': + return 'deleted' + case 'renamed': + return 'renamed' + default: + return 'modified' + } +} + +function compareSeverity(a: Severity, b: Severity): number { + const order = { low: 0, medium: 1, high: 2, critical: 3 } + return order[a] - order[b] +} + +// ============================================================================ +// Webhook events (from processors/webhook-events.ts) +// ============================================================================ + +/** + * Resolves a stored webhook delivery into a discriminated union the workflow + * can branch on. + */ +export async function loadEvent(deliveryId: string): Promise { + 'use step' + + const event = await loadEventRow(deliveryId) + const eventType = event.eventType + + switch (eventType) { + case GITHUB_EVENTS.PUSH: { + const parsed = parsePayload(pushEventSchema, event.payload, deliveryId) + return { + type: 'push', + deliveryId, + eventType, + branchName: extractBranchFromRef(parsed.ref), + sha: parsed.after, + isDeletion: isBranchDeletion(parsed.after), + } + } + + case GITHUB_EVENTS.PULL_REQUEST: { + const parsed = parsePayload( + pullRequestEventSchema, + event.payload, + deliveryId + ) + const relevantActions: string[] = [ + PR_ACTIONS.OPENED, + PR_ACTIONS.SYNCHRONIZE, + PR_ACTIONS.REOPENED, + PR_ACTIONS.CLOSED, + ] + return { + type: 'pull_request', + deliveryId, + eventType, + action: parsed.action, + isRelevant: relevantActions.includes(parsed.action), + } + } + + case GITHUB_EVENTS.INSTALLATION: + case GITHUB_EVENTS.INSTALLATION_REPOSITORIES: { + const payload = event.payload as { action?: string } + return { + type: 'installation', + deliveryId, + eventType, + action: payload.action ?? '', + } + } + + default: + return { type: 'unhandled', deliveryId, eventType } + } +} + +/** + * Records the outcome of a delivery. The processor did this in a try/catch + * around the whole job; the workflow now decides when to call it. + * + * The success path clears `error` as well as setting `processedAt`. Without + * that, an operator who uses GitHub's Redeliver to retry a delivery that + * previously failed - after fixing whatever caused the failure - ends up + * with a `webhook_events` row that has both `error` and `processedAt` set, + * which reads as "failed" in the one table an operator has to inspect a + * delivery's outcome, even though the redelivery succeeded. + */ +export async function markEventProcessed( + deliveryId: string, + error?: string +): Promise<{ deliveryId: string }> { + 'use step' + + await db + .update(webhookEvents) + .set( + error !== undefined + ? { error } + : { processedAt: new Date(), error: null } + ) + .where(eq(webhookEvents.deliveryId, deliveryId)) + + return { deliveryId } +} + +/** + * Upserts the branch a push touched, or retires it if the push deleted it. + * Returns null when there is nothing to sync: a non-branch ref, a branch + * deletion (once cleanup below has run), or an unknown repository. + * + * The processor enqueued branch-sync and (for non-default branches) overlap + * detection here. Both are now the workflow's job, which is what `isDefault` + * and the branch identifiers in the return value are for. + */ +export async function upsertBranch(deliveryId: string): Promise<{ + branchId: string + repositoryId: string + installationId: number + branchName: string + sha: string + isDefault: boolean +} | null> { + 'use step' + + const event = await loadEventRow(deliveryId) + const parsed = parsePayload(pushEventSchema, event.payload, deliveryId) + const branchName = extractBranchFromRef(parsed.ref) + + if (!branchName) { + console.log(`Ignoring non-branch ref: ${parsed.ref}`) + return null + } + + // A branch deletion push carries no tree to sync. It does carry state to + // retire: the old Fastify route did this inline, synchronously, in the + // request handler (apps/api/src/routes/webhooks.ts). That code is gone - + // this step is now the only place a deletion is ever acted on - so the + // cleanup has to happen here, not merely be skipped. + if (isBranchDeletion(parsed.after)) { + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.githubId, parsed.repository.id), + }) + + if (repo) { + const branch = await db.query.branches.findFirst({ + where: and(eq(branches.repositoryId, repo.id), eq(branches.name, branchName)), + }) + + if (branch) { + await db.delete(branchFiles).where(eq(branchFiles.branchId, branch.id)) + + // Any overlap naming this branch on either side no longer refers to + // anything that exists on GitHub. Resolve it the same way + // pruneRepositoryBranches does for time-pruned branches, rather than + // leaving it active and pointing at a deleted branch. + await db + .update(overlaps) + .set({ + status: 'resolved', + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where( + or(eq(overlaps.sourceBranchId, branch.id), eq(overlaps.targetBranchId, branch.id)) + ) + + await db.delete(branches).where(eq(branches.id, branch.id)) + console.log(`Cleaned up deleted branch: ${branchName} in ${repo.fullName}`) + } + } + + return null + } + + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.githubId, parsed.repository.id), + with: { installation: true }, + }) + + if (!repo) { + console.log(`Repository not found: ${parsed.repository.full_name}`) + return null + } + + const isDefault = branchName === repo.defaultBranch + + const existingBranch = await db.query.branches.findFirst({ + where: and(eq(branches.repositoryId, repo.id), eq(branches.name, branchName)), + }) + + let branchId: string + + if (existingBranch) { + await db + .update(branches) + .set({ + sha: parsed.after, + lastPusherGithubId: parsed.sender.id, + lastSeenAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(branches.id, existingBranch.id)) + branchId = existingBranch.id + } else { + const [newBranch] = await db + .insert(branches) + .values({ + repositoryId: repo.id, + name: branchName, + sha: parsed.after, + isDefault, + lastPusherGithubId: parsed.sender.id, + lastSeenAt: new Date(), + }) + .returning() + branchId = newBranch.id + } + + return { + branchId, + repositoryId: repo.id, + installationId: repo.installation.installationId, + branchName, + sha: parsed.after, + isDefault, + } +} + +/** + * Upserts the pull request a `pull_request` delivery describes. Returns null + * when nothing downstream should run: an ignored action, an unknown + * repository or branch, or a closed PR (the record is still updated, but the + * processor did not run detection for closed PRs and neither do we). + */ +export async function upsertPullRequest( + deliveryId: string +): Promise<{ repositoryId: string; branchId: string } | null> { + 'use step' + + const event = await loadEventRow(deliveryId) + const parsed = parsePayload(pullRequestEventSchema, event.payload, deliveryId) + + const relevantActions: string[] = [ + PR_ACTIONS.OPENED, + PR_ACTIONS.SYNCHRONIZE, + PR_ACTIONS.REOPENED, + PR_ACTIONS.CLOSED, + ] + if (!relevantActions.includes(parsed.action)) { + return null + } + + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.githubId, parsed.repository.id), + }) + + if (!repo) { + console.log(`Repository not found: ${parsed.repository.full_name}`) + return null + } + + const branch = await db.query.branches.findFirst({ + where: and( + eq(branches.repositoryId, repo.id), + eq(branches.name, parsed.pull_request.head.ref) + ), + }) + + if (!branch) { + console.log(`Branch not found: ${parsed.pull_request.head.ref}`) + return null + } + + let prState: 'open' | 'closed' | 'merged' = parsed.pull_request.state + if (parsed.action === PR_ACTIONS.CLOSED && parsed.pull_request.merged) { + prState = 'merged' + } + + await db + .insert(pullRequests) + .values({ + repositoryId: repo.id, + branchId: branch.id, + githubPrNumber: parsed.pull_request.number, + title: parsed.pull_request.title, + state: prState, + }) + .onConflictDoUpdate({ + target: [pullRequests.repositoryId, pullRequests.githubPrNumber], + set: { + title: parsed.pull_request.title, + state: prState, + updatedAt: new Date(), + }, + }) + + // Detection ran for open/reopened/synchronize only, never for closed. + if (parsed.action === PR_ACTIONS.CLOSED) { + return null + } + + return { repositoryId: repo.id, branchId: branch.id } +} + +/** + * Applies an `installation` or `installation_repositories` delivery. Returns + * the repositories that need a `syncRepository` run; the processor enqueued + * one maintenance job per repository here. + */ +export async function syncInstallation( + deliveryId: string +): Promise<{ repositoryIds: string[] }> { + 'use step' + + const event = await loadEventRow(deliveryId) + + if (event.eventType === GITHUB_EVENTS.INSTALLATION_REPOSITORIES) { + return await applyInstallationRepositories(event.payload) + } + + const parsed = parsePayload( + installationEventSchema, + event.payload, + deliveryId + ) + const repositoryIds: string[] = [] + + if (parsed.action === 'created') { + const accountType = parsed.installation.account.type + + let organizationId: string | null = null + + if (accountType === 'Organization') { + const [org] = await db + .insert(organizations) + .values({ + githubId: parsed.installation.account.id, + name: parsed.installation.account.login, + avatarUrl: parsed.installation.account.avatar_url, + }) + .onConflictDoUpdate({ + target: organizations.githubId, + set: { + name: parsed.installation.account.login, + avatarUrl: parsed.installation.account.avatar_url, + updatedAt: new Date(), + }, + }) + .returning() + organizationId = org.id + } + + // Link installation to user via sender.id (the person who installed) + let userId: string | null = null + const user = await db.query.users.findFirst({ + where: eq(users.githubId, parsed.sender.id), + }) + if (user) { + userId = user.id + } + + const [installation] = await db + .insert(githubAppInstallations) + .values({ + installationId: parsed.installation.id, + organizationId, + userId, + status: 'active', + }) + .onConflictDoUpdate({ + target: githubAppInstallations.installationId, + set: { + status: 'active', + updatedAt: new Date(), + }, + }) + .returning() + + // Link user to installation (many-to-many) + if (userId) { + await db + .insert(userInstallations) + .values({ userId, installationId: installation.id }) + .onConflictDoNothing() + } + + console.log( + `Installation created: ${parsed.installation.id} (userId: ${userId})` + ) + + if (parsed.repositories && parsed.repositories.length > 0) { + for (const repo of parsed.repositories) { + const [inserted] = await db + .insert(repositories) + .values({ + githubId: repo.id, + installationId: installation.id, + name: repo.name, + fullName: repo.full_name, + isPrivate: repo.private, + isActive: true, + }) + .onConflictDoUpdate({ + target: repositories.githubId, + set: { + isActive: true, + updatedAt: new Date(), + }, + }) + .returning() + + repositoryIds.push(inserted.id) + } + } + } else if (parsed.action === 'deleted') { + const existing = await db.query.githubAppInstallations.findFirst({ + where: eq(githubAppInstallations.installationId, parsed.installation.id), + }) + + if (existing) { + // Remove user-installation links for this installation + await db + .delete(userInstallations) + .where(eq(userInstallations.installationId, existing.id)) + } + + await db + .update(githubAppInstallations) + .set({ status: 'deleted', updatedAt: new Date() }) + .where(eq(githubAppInstallations.installationId, parsed.installation.id)) + + console.log(`Installation deleted: ${parsed.installation.id}`) + } + + return { repositoryIds } +} + +async function applyInstallationRepositories( + payload: Record +): Promise<{ repositoryIds: string[] }> { + const action = payload.action as string + const installationData = payload.installation as { id: number } + const repositoryIds: string[] = [] + + const installation = await db.query.githubAppInstallations.findFirst({ + where: eq(githubAppInstallations.installationId, installationData.id), + }) + + if (!installation) { + console.log(`Installation not found: ${installationData.id}`) + return { repositoryIds } + } + + if (action === 'added') { + const repos = payload.repositories_added as Array<{ + id: number + name: string + full_name: string + private: boolean + }> + + for (const repo of repos) { + const [inserted] = await db + .insert(repositories) + .values({ + githubId: repo.id, + installationId: installation.id, + name: repo.name, + fullName: repo.full_name, + isPrivate: repo.private, + isActive: true, + }) + .onConflictDoUpdate({ + target: repositories.githubId, + set: { + isActive: true, + updatedAt: new Date(), + }, + }) + .returning() + + repositoryIds.push(inserted.id) + } + } else if (action === 'removed') { + const repos = payload.repositories_removed as Array<{ id: number }> + + for (const repo of repos) { + await db + .update(repositories) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(repositories.githubId, repo.id)) + } + } + + return { repositoryIds } +} + +// ============================================================================ +// Branch sync (from processors/branch-sync.ts) +// ============================================================================ + +/** + * Rebuilds the file index for a branch. + * + * The processor caught a GitHub failure, set `changedFiles = []` and carried + * on into the delete, wiping the branch's file index whenever GitHub had a + * bad minute. Detection then read an empty index and resolved live overlaps. + * The fetch failure is now thrown, so the delete below is unreachable unless + * the fetch succeeded. + */ +export async function syncBranchFiles(input: { + repositoryId: string + branchName: string + sha: string + installationId: number +}): Promise<{ filesIndexed: number }> { + 'use step' + + const { repositoryId, branchName, sha, installationId } = input + + console.log(`Syncing branch: ${branchName} (${sha.slice(0, 7)})`) + + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.id, repositoryId), + with: { settings: true }, + }) + + if (!repo) { + throw new FatalError(`Repository not found: ${repositoryId}`) + } + + const branch = await db.query.branches.findFirst({ + where: and( + eq(branches.repositoryId, repositoryId), + eq(branches.name, branchName) + ), + }) + + if (!branch) { + throw new FatalError(`Branch not found: ${branchName}`) + } + + const ignoredPaths = + repo.settings?.ignoredPaths ?? DEFAULT_SETTINGS.IGNORED_PATHS + + const github = getGitHubClient() + const [owner, repoName] = repo.fullName.split('/') + + let changedFiles: CommitFile[] + + try { + // Compare branch to default branch to get all changed files + changedFiles = await github.getBranchFiles( + installationId, + owner, + repoName, + branchName, + repo.defaultBranch + ) + } catch (error) { + throw classifyGitHubError(error as never) + } + + const filteredFiles = changedFiles.filter((file) => { + return !ignoredPaths.some((pattern) => minimatch(file.filename, pattern)) + }) + + console.log( + `Found ${filteredFiles.length} changed files (${changedFiles.length} before filtering)` + ) + + // Replace the branch's file index with what GitHub just reported. + await db.delete(branchFiles).where(eq(branchFiles.branchId, branch.id)) + + if (filteredFiles.length > 0) { + await db.insert(branchFiles).values( + filteredFiles.map((file) => ({ + branchId: branch.id, + filePath: file.filename, + changeType: mapChangeType(file.status), + })) + ) + } + + await db + .update(branches) + .set({ + sha, + lastSeenAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(branches.id, branch.id)) + + await db + .update(repositories) + .set({ lastSyncedAt: new Date(), updatedAt: new Date() }) + .where(eq(repositories.id, repositoryId)) + + console.log(`Branch sync complete: ${branchName}`) + + return { filesIndexed: filteredFiles.length } +} + +// ============================================================================ +// Overlap detection (from processors/overlap-detection.ts) +// ============================================================================ + +/** + * Recomputes the overlaps for a branch. + * + * The processor enqueued a github-feedback job per open PR and a + * push-notification job per overlap. Both are now returned as + * `NotificationTarget[]` for the workflow to fan out. The notify decision + * itself is unchanged: new overlap, severity increase, or reactivation. + */ +export async function detectOverlaps(input: { + repositoryId: string + branchId: string +}): Promise<{ overlapsFound: number; notifications: NotificationTarget[] }> { + 'use step' + + const { repositoryId, branchId } = input + + console.log(`Detecting overlaps for branch: ${branchId}`) + + const notifications: NotificationTarget[] = [] + + const branch = await db.query.branches.findFirst({ + where: eq(branches.id, branchId), + with: { files: true, repository: { with: { settings: true } } }, + }) + + if (!branch) { + throw new FatalError(`Branch not found: ${branchId}`) + } + + if (branch.isDefault) { + console.log('Skipping default branch') + return { overlapsFound: 0, notifications } + } + + if (branch.files.length === 0) { + console.log('Branch has no tracked files') + return { overlapsFound: 0, notifications } + } + + const pruningDays = + branch.repository.settings?.pruningDays ?? DEFAULT_SETTINGS.PRUNING_DAYS + + const staleDate = new Date() + staleDate.setDate(staleDate.getDate() - pruningDays) + + const otherBranches = await db.query.branches.findMany({ + where: and( + eq(branches.repositoryId, repositoryId), + ne(branches.id, branchId), + eq(branches.isDefault, false), + gt(branches.lastSeenAt, staleDate) + ), + with: { files: true }, + }) + + console.log(`Comparing against ${otherBranches.length} other branches`) + + const branchFilePaths = new Set(branch.files.map((f) => f.filePath)) + const detectedOverlaps: Array<{ + targetBranchId: string + files: Array<{ + filePath: string + sourceChangeType: string + targetChangeType: string + }> + }> = [] + + for (const otherBranch of otherBranches) { + const overlappingFiles: Array<{ + filePath: string + sourceChangeType: string + targetChangeType: string + }> = [] + + for (const otherFile of otherBranch.files) { + if (branchFilePaths.has(otherFile.filePath)) { + const sourceFile = branch.files.find( + (f) => f.filePath === otherFile.filePath + ) + if (sourceFile) { + overlappingFiles.push({ + filePath: otherFile.filePath, + sourceChangeType: sourceFile.changeType, + targetChangeType: otherFile.changeType, + }) + } + } + } + + if (overlappingFiles.length > 0) { + detectedOverlaps.push({ + targetBranchId: otherBranch.id, + files: overlappingFiles, + }) + } + } + + console.log(`Found ${detectedOverlaps.length} overlapping branches`) + + for (const detected of detectedOverlaps) { + const severity = calculateSeverity(detected.files.length) + + // Check if overlap already exists (in either direction) + const existingOverlap = await db.query.overlaps.findFirst({ + where: sql` + ${overlaps.repositoryId} = ${repositoryId} + AND ( + (${overlaps.sourceBranchId} = ${branchId} AND ${overlaps.targetBranchId} = ${detected.targetBranchId}) + OR (${overlaps.sourceBranchId} = ${detected.targetBranchId} AND ${overlaps.targetBranchId} = ${branchId}) + ) + `, + }) + + let overlapId: string + let isNew = false + let severityIncreased = false + let wasReactivated = false + + if (existingOverlap) { + const oldSeverity = existingOverlap.severity + severityIncreased = compareSeverity(severity, oldSeverity as Severity) > 0 + wasReactivated = + existingOverlap.status === 'resolved' || + existingOverlap.status === 'ignored' + + await db + .update(overlaps) + .set({ + fileCount: detected.files.length, + severity, + status: 'active', + resolvedAt: null, + updatedAt: new Date(), + }) + .where(eq(overlaps.id, existingOverlap.id)) + + overlapId = existingOverlap.id + + await db.delete(overlapFiles).where(eq(overlapFiles.overlapId, overlapId)) + } else { + isNew = true + const [newOverlap] = await db + .insert(overlaps) + .values({ + repositoryId, + sourceBranchId: branchId, + targetBranchId: detected.targetBranchId, + fileCount: detected.files.length, + severity, + status: 'active', + detectedAt: new Date(), + }) + .returning() + + overlapId = newOverlap.id + } + + await db.insert(overlapFiles).values( + detected.files.map((f) => ({ + overlapId, + filePath: f.filePath, + sourceChangeType: f.sourceChangeType, + targetChangeType: f.targetChangeType, + })) + ) + + // Notify on: new overlaps, severity increases, or reactivated overlaps + // (previously resolved/ignored but the file was edited again) + const shouldNotify = + (isNew && branch.repository.settings?.notifyOnNewOverlap !== false) || + (severityIncreased && + branch.repository.settings?.notifyOnSeverityIncrease !== false) || + wasReactivated + + if (shouldNotify) { + // Any open PR for this branch gets a check run. + const openPRs = await db.query.pullRequests.findMany({ + where: and( + eq(pullRequests.branchId, branchId), + eq(pullRequests.state, 'open') + ), + }) + + // The push notification goes to the developer on the OTHER branch: + // if A pushed and overlaps with B, notify B's developer. + notifications.push({ + repositoryId, + overlapId, + targetBranchId: detected.targetBranchId, + pullRequestIds: openPRs.map((pr) => pr.id), + }) + } + } + + // Resolve overlaps that no longer have overlapping files + const currentOverlaps = await db.query.overlaps.findMany({ + where: and( + eq(overlaps.repositoryId, repositoryId), + sql`(${overlaps.sourceBranchId} = ${branchId} OR ${overlaps.targetBranchId} = ${branchId})`, + eq(overlaps.status, 'active') + ), + }) + + const activeTargetIds = new Set(detectedOverlaps.map((o) => o.targetBranchId)) + + for (const overlap of currentOverlaps) { + const otherBranchId = + overlap.sourceBranchId === branchId + ? overlap.targetBranchId + : overlap.sourceBranchId + + if (!activeTargetIds.has(otherBranchId)) { + await db + .update(overlaps) + .set({ + status: 'resolved', + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(overlaps.id, overlap.id)) + + console.log(`Overlap resolved: ${overlap.id}`) + } + } + + return { overlapsFound: detectedOverlaps.length, notifications } +} + +// ============================================================================ +// GitHub feedback (from processors/github-feedback.ts) +// ============================================================================ + +/** + * Publishes the overlap check run on a pull request. + * + * The processor logged and swallowed `createCheckRun` failures, then recorded + * a `prAlerts` row with a null check run id as if the alert had been + * delivered. The failure is now classified and thrown, so a transient GitHub + * error is retried instead of being recorded as a delivered alert. + */ +export async function postCheckRun(input: { + repositoryId: string + pullRequestId: string + overlapId: string +}): Promise<{ checkRunId: number | null }> { + 'use step' + + const { repositoryId, pullRequestId, overlapId } = input + + console.log( + `Sending GitHub feedback for PR: ${pullRequestId}, overlap: ${overlapId}` + ) + + const pr = await db.query.pullRequests.findFirst({ + where: eq(pullRequests.id, pullRequestId), + with: { + branch: true, + repository: { + with: { + installation: true, + }, + }, + }, + }) + + if (!pr) { + throw new FatalError(`Pull request not found: ${pullRequestId}`) + } + + const overlap = await db.query.overlaps.findFirst({ + where: eq(overlaps.id, overlapId), + with: { + files: true, + sourceBranch: true, + targetBranch: true, + }, + }) + + if (!overlap) { + throw new FatalError(`Overlap not found: ${overlapId}`) + } + + // Report every active overlap on this branch, not just the trigger. + const allOverlaps = await db.query.overlaps.findMany({ + where: and( + eq(overlaps.repositoryId, repositoryId), + eq(overlaps.status, 'active') + ), + with: { + files: true, + sourceBranch: true, + targetBranch: true, + }, + }) + + const branchOverlaps = allOverlaps.filter( + (o) => o.sourceBranchId === pr.branch.id || o.targetBranchId === pr.branch.id + ) + + if (branchOverlaps.length === 0) { + console.log('No active overlaps for this branch') + return { checkRunId: null } + } + + // Looked up before calling GitHub so a retry of this step (after GitHub + // succeeded but the step failed before returning) finds the check run this + // attempt - or a prior attempt - already recorded, and updates it instead + // of creating a duplicate. + const existingAlert = await db.query.prAlerts.findFirst({ + where: and( + eq(prAlerts.pullRequestId, pullRequestId), + eq(prAlerts.overlapId, overlapId) + ), + }) + + const github = getGitHubClient() + const [owner, repoName] = pr.repository.fullName.split('/') + const installationId = pr.repository.installation.installationId + + const overlapData = branchOverlaps.map((o) => { + const otherBranch = + o.sourceBranchId === pr.branch.id ? o.targetBranch : o.sourceBranch + + return { + branchName: otherBranch.name, + files: o.files.map((f) => f.filePath), + fileCount: o.fileCount, + severity: o.severity as Severity, + } + }) + + // Create or update the check run (no PR comments - GitHub already shows + // conflicts). + const { conclusion, title, summary } = formatCheckRunSummary(overlapData) + + let checkRunId: number + + if (existingAlert?.checkRunId) { + checkRunId = existingAlert.checkRunId + try { + await github.updateCheckRun( + installationId, + owner, + repoName, + checkRunId, + conclusion, + title, + summary + ) + console.log(`Check run updated: ${checkRunId}`) + } catch (error) { + throw classifyGitHubError(error as never) + } + } else { + try { + checkRunId = await github.createCheckRun( + installationId, + owner, + repoName, + pr.branch.sha, + 'Overlap Detection', + conclusion, + title, + summary + ) + console.log(`Check run created: ${checkRunId}`) + } catch (error) { + throw classifyGitHubError(error as never) + } + + // Recorded immediately so a retry after this point finds the check run + // and updates it instead of creating a second one. + if (existingAlert) { + await db + .update(prAlerts) + .set({ checkRunId }) + .where(eq(prAlerts.id, existingAlert.id)) + } else { + await db.insert(prAlerts).values({ + pullRequestId, + overlapId, + alertType: 'check_run', + checkRunId, + }) + } + } + + return { checkRunId } +} + +// ============================================================================ +// Push notification (from processors/push-notification.ts) +// ============================================================================ + +/** + * Sends the web push notification for an overlap to the last pusher on the + * target branch. + */ +export async function sendPush(input: { + repositoryId: string + overlapId: string + targetBranchId: string +}): Promise<{ sent: number }> { + 'use step' + + const { repositoryId, overlapId, targetBranchId } = input + + const appUrl = process.env.APP_URL || 'http://localhost:3000' + const vapidPublicKey = process.env.VAPID_PUBLIC_KEY + const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY + const vapidSubject = process.env.VAPID_SUBJECT + + if (!vapidPublicKey || !vapidPrivateKey || !vapidSubject) { + console.log('VAPID keys not configured, skipping push notification') + return { sent: 0 } + } + + webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey) + + const overlap = await db.query.overlaps.findFirst({ + where: eq(overlaps.id, overlapId), + with: { + sourceBranch: true, + targetBranch: true, + files: true, + }, + }) + + if (!overlap) { + console.log(`Overlap not found: ${overlapId}`) + return { sent: 0 } + } + + const targetBranch = await db.query.branches.findFirst({ + where: eq(branches.id, targetBranchId), + }) + + if (!targetBranch?.lastPusherGithubId) { + console.log(`No pusher info for branch: ${targetBranchId}`) + return { sent: 0 } + } + + const user = await db.query.users.findFirst({ + where: eq(users.githubId, targetBranch.lastPusherGithubId), + }) + + if (!user) { + console.log( + `User not found for githubId: ${targetBranch.lastPusherGithubId}` + ) + return { sent: 0 } + } + + const subscriptions = await db.query.pushSubscriptions.findMany({ + where: eq(pushSubscriptions.userId, user.id), + }) + + if (subscriptions.length === 0) { + console.log(`No push subscriptions for user: ${user.id}`) + return { sent: 0 } + } + + // Orient so the recipient's branch appears first (same as UI auto-orient) + const isRecipientSource = overlap.sourceBranchId === targetBranchId + const yourBranch = isRecipientSource + ? overlap.sourceBranch.name + : overlap.targetBranch.name + const otherBranch = isRecipientSource + ? overlap.targetBranch.name + : overlap.sourceBranch.name + const fileCount = overlap.files.length + + const payload = JSON.stringify({ + title: `Overlap Detected · ${fileCount} file${fileCount !== 1 ? 's' : ''}`, + body: `${yourBranch} ↔ ${otherBranch}`, + url: `${appUrl}/repositories/${repositoryId}`, + tag: `overlap-${overlapId}`, + }) + + let sent = 0 + + for (const sub of subscriptions) { + try { + await webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: { + p256dh: sub.p256dh, + auth: sub.auth, + }, + }, + payload + ) + sent++ + } catch (err) { + const statusCode = (err as { statusCode?: number }).statusCode + // Remove expired/invalid subscriptions + if (statusCode === 404 || statusCode === 410) { + await db + .delete(pushSubscriptions) + .where(eq(pushSubscriptions.id, sub.id)) + console.log(`Removed expired subscription: ${sub.id}`) + } else { + const message = err instanceof Error ? err.message : String(err) + console.error(`Failed to send push to ${sub.endpoint}:`, message) + } + } + } + + console.log( + `Sent ${sent}/${subscriptions.length} push notifications for overlap ${overlapId}` + ) + return { sent } +} + +// ============================================================================ +// Maintenance (from processors/maintenance.ts) +// ============================================================================ + +/** + * Rows per `INSERT ... ON CONFLICT` statement in `syncRepository`'s bulk + * branch upsert. Well under the ~13106-row protocol ceiling documented on + * `syncRepository` (margin for the fact that the ceiling is derived from a + * fixed 5-columns-plus-`updatedAt` shape that could grow), and far above any + * repository this system handles in practice. + */ +export const BRANCH_INSERT_CHUNK_SIZE = 5000 + +/** Splits `items` into consecutive slices of at most `size` elements each. */ +function chunkArray(items: readonly T[], size: number): T[][] { + const chunks: T[][] = [] + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)) + } + return chunks +} + +/** + * Reconciles the local branch list for a repository against GitHub. + * + * The writes below used to be one UPDATE-or-INSERT per remote branch, + * followed by a second loop issuing one UPDATE per local branch missing from + * the remote. On a repository with a few thousand branches that is thousands + * of sequential database round trips inside a single step invocation, on top + * of the ~30 sequential paginated GitHub API calls `getBranches` already + * makes at `per_page: 100` (see `packages/github/src/client.ts`). That could + * exceed the function's `maxDuration` (see `apps/web/vite.config.ts`), at + * which point the step times out, the workflow retries it, and the retry + * does the identical sequential work and times out again - the repository + * never finishes syncing. + * + * Both loops are now single set-based statements: a bulk insert with + * `onConflictDoUpdate` for the branches GitHub reports, and one UPDATE with + * `inArray` for the local branches GitHub no longer reports. `setWhere` + * keeps the upsert's update side a no-op when the remote SHA matches what is + * already stored, and `isDefault` is intentionally left out of the update + * `set` - both match the old loop, which only wrote on an actual SHA change + * and never touched `isDefault` after the initial insert. + * + * `added`/`updated` are computed from the already-fetched `localBranches` and + * `remoteBranches` lists rather than from what the bulk statement reports + * touching, so they stay exact and require no extra round trip. + * + * The bulk insert is chunked at `BRANCH_INSERT_CHUNK_SIZE` rows. The Postgres + * wire protocol encodes the Bind message's parameter count as an int16, so a + * single statement breaks once it needs more than 65535 bound parameters. + * Each row here binds 5 columns plus `updatedAt` again in the conflict + * `set`, i.e. `5n + 1` parameters for n rows - a hard ceiling at n = 13106 + * (13107 rows needs 65536 parameters and fails outright). Past that point + * every attempt fails identically: the workflow retries, hits the same + * protocol error, and the repository never finishes syncing - the same + * failure this function's batching was written to prevent, just relocated + * from a load-sensitive timeout to a deterministic statement error. Chunking + * keeps each statement well under the ceiling; chunks are independently + * idempotent (same `onConflictDoUpdate` as before), so the only property + * lost versus one statement is cross-chunk atomicity, which the original + * per-branch loop never had either. + * + * The `inArray` UPDATE below is not chunked: it binds one parameter per id, + * so its ceiling is ~65533 ids - about five times higher than the insert's, + * and far beyond any repository this system handles in practice. Chunking it + * for symmetry would add complexity without a corresponding real ceiling to + * defend against. + */ +export async function syncRepository(repositoryId: string): Promise<{ + added: number + updated: number + markedForDeletion: number +}> { + 'use step' + + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.id, repositoryId), + with: { installation: true }, + }) + + if (!repo) { + throw new FatalError(`Repository not found: ${repositoryId}`) + } + + const github = getGitHubClient() + const [owner, repoName] = repo.fullName.split('/') + + let remoteBranches + try { + remoteBranches = await github.getBranches( + repo.installation.installationId, + owner, + repoName + ) + } catch (error) { + throw classifyGitHubError(error as never) + } + + const localBranches = await db.query.branches.findMany({ + where: eq(branches.repositoryId, repositoryId), + }) + + const localBranchMap = new Map(localBranches.map((b) => [b.name, b])) + const remoteBranchNames = new Set(remoteBranches.map((b) => b.name)) + + const addedCount = remoteBranches.filter( + (remote) => !localBranchMap.has(remote.name) + ).length + const updatedCount = remoteBranches.filter((remote) => { + const local = localBranchMap.get(remote.name) + return local !== undefined && local.sha !== remote.sha + }).length + + for (const chunk of chunkArray(remoteBranches, BRANCH_INSERT_CHUNK_SIZE)) { + await db + .insert(branches) + .values( + chunk.map((remote) => ({ + repositoryId, + name: remote.name, + sha: remote.sha, + isDefault: remote.name === repo.defaultBranch, + lastSeenAt: new Date(), + })) + ) + .onConflictDoUpdate({ + target: [branches.repositoryId, branches.name], + set: { + sha: sql`excluded.sha`, + lastSeenAt: sql`excluded.last_seen_at`, + updatedAt: new Date(), + }, + setWhere: sql`${branches.sha} <> excluded.sha`, + }) + } + + const missingBranchIds = localBranches + .filter((local) => !remoteBranchNames.has(local.name)) + .map((local) => local.id) + + if (missingBranchIds.length > 0) { + await db + .update(branches) + .set({ + lastSeenAt: new Date(0), // Epoch = very old + updatedAt: new Date(), + }) + .where(inArray(branches.id, missingBranchIds)) + } + + await db + .update(repositories) + .set({ lastSyncedAt: new Date(), updatedAt: new Date() }) + .where(eq(repositories.id, repositoryId)) + + console.log( + `Synced repository: added ${addedCount}, updated ${updatedCount}, marked ${missingBranchIds.length} for deletion` + ) + + return { + added: addedCount, + updated: updatedCount, + markedForDeletion: missingBranchIds.length, + } +} + +/** + * Returns the ids of every active repository, for `pruneBranchesWorkflow` to + * fan out over - one durable step per repository, the same shape + * `syncInstallation` already returns for `processWebhook` to loop over with + * `syncRepository`. See `pruneRepositoryBranches` for why the loop moved out + * of a single step and into the workflow. + */ +export async function getActiveRepositoryIds(): Promise<{ + repositoryIds: string[] +}> { + 'use step' + + const repos = await db.query.repositories.findMany({ + where: eq(repositories.isActive, true), + columns: { id: true }, + }) + + return { repositoryIds: repos.map((repo) => repo.id) } +} + +/** + * Deletes branches nobody has pushed to inside ONE repository's pruning + * window, resolving any overlap that involved them. + * + * This used to be the body of a `for` loop inside a single `pruneStaleBranches` + * step that walked every active repository in one function invocation. A + * step is one function invocation, so that loop's runtime scaled with the + * number of installations within one invocation's duration budget - the + * opposite of what a durable workflow is for. `syncRepository` already gets + * this right for the installation workflow: `processWebhook` loops over + * repository ids and awaits one `syncRepository` step per repository. + * `pruneBranchesWorkflow` now does the same here, awaiting this step once per + * id returned by `getActiveRepositoryIds`. The pruning logic itself is + * unchanged from the old per-repository loop body. + */ +export async function pruneRepositoryBranches( + repositoryId: string +): Promise<{ prunedBranches: number }> { + 'use step' + + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.id, repositoryId), + with: { settings: true }, + }) + + if (!repo) { + return { prunedBranches: 0 } + } + + const pruningDays = repo.settings?.pruningDays ?? DEFAULT_SETTINGS.PRUNING_DAYS + const staleDate = new Date() + staleDate.setDate(staleDate.getDate() - pruningDays) + + // Find stale branches (non-default, not seen recently) + const staleBranches = await db.query.branches.findMany({ + where: and( + eq(branches.repositoryId, repo.id), + eq(branches.isDefault, false), + lt(branches.lastSeenAt, staleDate) + ), + }) + + if (staleBranches.length === 0) return { prunedBranches: 0 } + + const staleBranchIds = staleBranches.map((b) => b.id) + + await db + .delete(branchFiles) + .where(inArray(branchFiles.branchId, staleBranchIds)) + + await db + .update(overlaps) + .set({ + status: 'resolved', + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where( + sql`(${overlaps.sourceBranchId} IN (${sql.join(staleBranchIds, sql`, `)}) OR ${overlaps.targetBranchId} IN (${sql.join(staleBranchIds, sql`, `)}))` + ) + + await db.delete(branches).where(inArray(branches.id, staleBranchIds)) + + console.log( + `Pruned ${staleBranches.length} stale branches from ${repo.fullName}` + ) + + return { prunedBranches: staleBranches.length } +} + +/** + * Drops webhook event rows older than seven days. + */ +export async function cleanupOldEvents(): Promise<{ cleaned: boolean }> { + 'use step' + + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - 7) + + await db.delete(webhookEvents).where(lt(webhookEvents.createdAt, cutoffDate)) + + console.log('Cleaned up old webhook events') + return { cleaned: true } +} diff --git a/apps/web/src/workflows/test-notify.ts b/apps/web/src/workflows/test-notify.ts new file mode 100644 index 0000000..230f517 --- /dev/null +++ b/apps/web/src/workflows/test-notify.ts @@ -0,0 +1,20 @@ +/** + * Dev-only notification workflow. + * + * The Fastify app let a developer replay the push notification for an existing + * overlap by enqueueing a BullMQ `pushNotification` job directly from the route + * handler. Routes cannot invoke a step's durable semantics on their own, so the + * dev-only endpoint starts this workflow instead. + */ + +import { sendPush } from './steps' + +export async function testNotifyWorkflow( + repositoryId: string, + overlapId: string, + targetBranchId: string +): Promise<{ sent: number }> { + 'use workflow' + + return await sendPush({ repositoryId, overlapId, targetBranchId }) +} diff --git a/apps/web/test/integration/build-shims.ts b/apps/web/test/integration/build-shims.ts new file mode 100644 index 0000000..0d5a1f3 --- /dev/null +++ b/apps/web/test/integration/build-shims.ts @@ -0,0 +1,82 @@ +/** + * Vitest `globalSetup` that installs the test doubles the workflow integration + * tests need for `@overlap/db` and `@overlap/github`. + * + * Why this exists rather than `vi.mock()` or a Vitest alias: + * + * `@workflow/vitest` does not run steps through Vitest's module graph. Before + * the suite starts it esbuild-bundles every `"use step"` function into + * `.workflow-vitest/steps.mjs` and the worker imports that bundle with a plain + * `import()`. Vitest never sees it, so `vi.mock()` and `resolve.alias` have no + * effect on it - a point the SDK's own testing guide makes. + * + * The bundler leaves bare package specifiers alone (`steps.mjs` still contains + * `import { db, branches, ... } from "@overlap/db"`), so those imports are + * resolved by Node at runtime, from the directory the bundle sits in. Node + * looks in `.workflow-vitest/node_modules` before `apps/web/node_modules`, so + * dropping a package there shadows the workspace one for the step bundle only. + * Nothing outside `.workflow-vitest/` is affected, and no production code + * changes to make the steps injectable. + */ + +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +const here = dirname(fileURLToPath(import.meta.url)) +const webRoot = resolve(here, '../..') +const repoRoot = resolve(webRoot, '../..') +const shimRoot = join(webRoot, '.workflow-vitest', 'node_modules', '@overlap') +const migrationsDir = join(repoRoot, 'packages', 'db', 'drizzle') + +async function writeShimPackage(name: string, entry: string): Promise { + const outDir = join(shimRoot, name) + await mkdir(outDir, { recursive: true }) + + await writeFile( + join(outDir, 'package.json'), + `${JSON.stringify( + { + name: `@overlap/${name}`, + version: '0.0.0-integration-test', + private: true, + type: 'module', + main: './index.mjs', + // Every subpath the real package exposes maps to the same module, so + // a step importing `@overlap/db/schema` still gets the shim. + exports: { + '.': './index.mjs', + './schema': './index.mjs', + './client': './index.mjs', + './webhooks': './index.mjs', + }, + }, + null, + 2 + )}\n` + ) + + await build({ + entryPoints: [join(here, entry)], + outfile: join(outDir, 'index.mjs'), + bundle: true, + platform: 'node', + format: 'esm', + target: 'es2022', + // Kept external so the shim and the step bundle share one copy: drizzle + // table objects are compared by identity inside a query, and PGlite must + // not be instantiated twice. + external: ['drizzle-orm', 'drizzle-orm/*', '@electric-sql/pglite'], + define: { + __OVERLAP_MIGRATIONS_DIR__: JSON.stringify(migrationsDir), + }, + logLevel: 'error', + }) +} + +export async function setup(): Promise { + await writeShimPackage('db', 'db-shim.ts') + await writeShimPackage('github', 'github-shim.ts') + await writeShimPackage('shared', 'shared-shim.ts') +} diff --git a/apps/web/test/integration/db-shim.ts b/apps/web/test/integration/db-shim.ts new file mode 100644 index 0000000..efa765c --- /dev/null +++ b/apps/web/test/integration/db-shim.ts @@ -0,0 +1,110 @@ +/** + * Test-only stand-in for `@overlap/db`. + * + * `build-shims.ts` bundles this file to + * `.workflow-vitest/node_modules/@overlap/db/index.mjs`, which Node resolves + * ahead of the real workspace package when it loads the generated step + * bundle. See `build-shims.ts` for why that is the seam. + * + * The schema, the relations and every query the steps issue are the real + * thing: the only substitution is the connection. Instead of the postgres.js + * socket client in `packages/db/src/client.ts` this points drizzle at PGlite, + * a WebAssembly build of Postgres that runs inside the test process, and + * applies the project's own `packages/db/drizzle/*.sql` migrations to it. The + * steps therefore execute their real SQL - including the raw `sql` templates + * in `detectOverlaps` and the relational `with:` loads - against the real + * schema, with no query faking anywhere. + * + * `@electric-sql/pglite` is deliberately a devDependency of the **workspace + * root**, not of this app. It is an optional peer of drizzle-orm, and pnpm + * keys a package instance by its resolved peer set - so declaring it in any + * individual package gives that package its own drizzle-orm variant, and the + * schema ends up built by one copy and queried by another. At the root, + * pnpm's `resolve-peers-from-workspace-root` satisfies the peer identically + * for every workspace project, so `apps/web`, `apps/api`, `apps/worker` and + * `packages/db` all share a single drizzle-orm. Do not move it into a package + * manifest: `pnpm typecheck` at the repo root will fail with + * "Types have separate declarations of a private property 'shouldInlineParams'". + */ + +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { PGlite } from '@electric-sql/pglite' +import { drizzle } from 'drizzle-orm/pglite' +import * as schema from '@overlap/db/schema' + +export * from '@overlap/db/schema' + +/** Absolute path to `packages/db/drizzle`, injected by `build-shims.ts`. */ +declare const __OVERLAP_MIGRATIONS_DIR__: string + +export type TestDatabase = ReturnType> + +export type DbHarness = { + pglite: PGlite + db: TestDatabase + schema: typeof schema + /** Empties every table. Call between tests. */ + reset(): Promise +} + +async function migrate(pglite: PGlite): Promise { + const files = (await readdir(__OVERLAP_MIGRATIONS_DIR__)) + .filter((file) => file.endsWith('.sql')) + .sort() + + for (const file of files) { + const contents = await readFile(join(__OVERLAP_MIGRATIONS_DIR__, file), 'utf8') + + // drizzle-kit separates statements with this marker rather than `;`, + // because a statement may legitimately contain one. + for (const statement of contents.split('--> statement-breakpoint')) { + const trimmed = statement.trim() + if (trimmed) { + await pglite.exec(trimmed) + } + } + } +} + +async function createHarness(): Promise { + const pglite = new PGlite() + await pglite.waitReady + await migrate(pglite) + + const db = drizzle(pglite, { schema }) + + return { + pglite, + db, + schema, + async reset() { + // TRUNCATE rather than re-running the migrations: it is a great deal + // faster and leaves the schema, indexes and constraints in place. + await pglite.exec(` + DO $$ + DECLARE target RECORD; + BEGIN + FOR target IN + SELECT tablename FROM pg_tables WHERE schemaname = 'public' + LOOP + EXECUTE 'TRUNCATE TABLE ' || quote_ident(target.tablename) || ' CASCADE'; + END LOOP; + END $$; + `) + }, + } +} + +/** + * One database per worker process, no matter how many module graphs load this + * file. The step bundle imports it natively while `harness.ts` may reach it + * through Vitest's module runner; without this rendezvous the tests would seed + * one Postgres instance and the steps would query another. + */ +const KEY = '__overlapDbHarness__' +const scope = globalThis as unknown as Record> +scope[KEY] ??= createHarness() + +export const __harness: DbHarness = await scope[KEY] +export const db: TestDatabase = __harness.db diff --git a/apps/web/test/integration/fixtures.ts b/apps/web/test/integration/fixtures.ts new file mode 100644 index 0000000..1007fa6 --- /dev/null +++ b/apps/web/test/integration/fixtures.ts @@ -0,0 +1,173 @@ +/** + * Seed data for the workflow integration tests. + * + * These go into a real Postgres through the real schema, so anything the + * steps' queries rely on - foreign keys, unique indexes, defaults - has to + * actually hold. + */ + +import { eq } from 'drizzle-orm' +import type { Harness } from './harness.js' + +export const REPO_GITHUB_ID = 424_242 +export const INSTALLATION_ID = 999 +export const REPO_FULL_NAME = 'acme/widgets' +export const DEFAULT_BRANCH = 'main' +export const PUSHED_BRANCH = 'feature/checkout' +export const HEAD_SHA = 'b'.repeat(40) + +/** A `push` payload GitHub would send, shaped to pass `pushEventSchema`. */ +export function pushPayload( + overrides: Record = {} +): Record { + return { + ref: `refs/heads/${PUSHED_BRANCH}`, + before: 'a'.repeat(40), + after: HEAD_SHA, + repository: { + id: REPO_GITHUB_ID, + name: 'widgets', + full_name: REPO_FULL_NAME, + default_branch: DEFAULT_BRANCH, + private: false, + }, + sender: { id: 4242, login: 'octocat' }, + installation: { id: INSTALLATION_ID }, + commits: [ + { + id: HEAD_SHA, + added: ['src/checkout.ts'], + modified: ['src/shared.ts'], + removed: [], + }, + ], + ...overrides, + } +} + +export type SeededRepo = { + repositoryId: string + pushedBranchId: string +} + +/** + * A repository with one installation and the branch the push targets. + * + * The pushed branch is seeded with an *empty* file index on purpose: that is + * what lets the ordering test tell "detection ran after the sync" apart from + * "detection ran at some point". Detection short-circuits on a branch with no + * tracked files, so any overlap it reports can only have come from an index + * `syncBranchFiles` had already written. + */ +export async function seedRepository(harness: Harness): Promise { + const { db, schema } = harness + + const [installation] = await db + .insert(schema.githubAppInstallations) + .values({ installationId: INSTALLATION_ID, status: 'active' }) + .returning() + + const [repository] = await db + .insert(schema.repositories) + .values({ + githubId: REPO_GITHUB_ID, + installationId: installation.id, + name: 'widgets', + fullName: REPO_FULL_NAME, + defaultBranch: DEFAULT_BRANCH, + }) + .returning() + + const [pushedBranch] = await db + .insert(schema.branches) + .values({ + repositoryId: repository.id, + name: PUSHED_BRANCH, + sha: 'a'.repeat(40), + isDefault: false, + lastPusherGithubId: 4242, + lastSeenAt: new Date(), + }) + .returning() + + return { repositoryId: repository.id, pushedBranchId: pushedBranch.id } +} + +/** Another active branch in the repository, with a file index of its own. */ +export async function seedBranchWithFiles( + harness: Harness, + repositoryId: string, + name: string, + filePaths: string[] +): Promise { + const { db, schema } = harness + + const [branch] = await db + .insert(schema.branches) + .values({ + repositoryId, + name, + sha: 'c'.repeat(40), + isDefault: false, + lastPusherGithubId: 7, + lastSeenAt: new Date(), + }) + .returning() + + if (filePaths.length > 0) { + await db.insert(schema.branchFiles).values( + filePaths.map((filePath) => ({ + branchId: branch.id, + filePath, + changeType: 'modified', + })) + ) + } + + return branch.id +} + +export async function seedOpenPullRequest( + harness: Harness, + repositoryId: string, + branchId: string, + githubPrNumber: number +): Promise { + const { db, schema } = harness + + const [pullRequest] = await db + .insert(schema.pullRequests) + .values({ + repositoryId, + branchId, + githubPrNumber, + title: `PR #${githubPrNumber}`, + state: 'open', + }) + .returning() + + return pullRequest.id +} + +export async function seedWebhookDelivery( + harness: Harness, + deliveryId: string, + payload: Record, + eventType = 'push' +): Promise { + const { db, schema } = harness + + await db.insert(schema.webhookEvents).values({ + eventType, + deliveryId, + payload, + dispatchedAt: new Date(), + }) +} + +export async function webhookEventRow(harness: Harness, deliveryId: string) { + const { db, schema } = harness + return db.query.webhookEvents.findFirst({ + where: eq(schema.webhookEvents.deliveryId, deliveryId), + }) +} diff --git a/apps/web/test/integration/github-shim.ts b/apps/web/test/integration/github-shim.ts new file mode 100644 index 0000000..5573c80 --- /dev/null +++ b/apps/web/test/integration/github-shim.ts @@ -0,0 +1,109 @@ +/** + * Test-only stand-in for `@overlap/github`. + * + * `build-shims.ts` bundles this file to + * `.workflow-vitest/node_modules/@overlap/github/index.mjs`, which Node + * resolves ahead of the real workspace package when it loads the generated + * step bundle. See `build-shims.ts` for why that is the seam. + * + * Only `getGitHubClient` is faked - it is the one export that opens a socket. + * `extractBranchFromRef`, `isBranchDeletion` and `formatCheckRunSummary` are + * pure and are re-exported from the real package, because the steps' handling + * of refs, deletions and check-run bodies is part of what is under test. + */ + +export { + extractBranchFromRef, + isBranchDeletion, + isBranchCreation, + formatOverlapComment, + formatCheckRunSummary, +} from '@overlap/github/webhooks' + +import { getGitHubFakeState, type FakeCommitFile } from './github-state.js' + +const delay = (ms: number) => + ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : undefined + +class FakeGitHubClient { + async getBranchFiles( + installationId: number, + owner: string, + repo: string, + branchName: string, + defaultBranch: string + ): Promise { + const state = getGitHubFakeState() + state.calls.push({ + method: 'getBranchFiles', + args: [installationId, owner, repo, branchName, defaultBranch], + }) + + await delay(state.getBranchFilesDelayMs) + + return state.branchFiles[branchName] ?? [] + } + + async createCheckRun( + installationId: number, + owner: string, + repo: string, + headSha: string, + name: string, + conclusion: string, + title: string, + summary: string + ): Promise { + const state = getGitHubFakeState() + state.calls.push({ + method: 'createCheckRun', + args: [ + installationId, + owner, + repo, + headSha, + name, + conclusion, + title, + summary, + ], + }) + + if (state.createCheckRunError) { + throw state.createCheckRunError + } + + return state.nextCheckRunId++ + } + + async updateCheckRun( + installationId: number, + owner: string, + repo: string, + checkRunId: number, + conclusion: string, + title: string, + summary: string + ): Promise { + const state = getGitHubFakeState() + state.calls.push({ + method: 'updateCheckRun', + args: [ + installationId, + owner, + repo, + checkRunId, + conclusion, + title, + summary, + ], + }) + } +} + +let client: FakeGitHubClient | null = null + +export function getGitHubClient(): FakeGitHubClient { + client ??= new FakeGitHubClient() + return client +} diff --git a/apps/web/test/integration/github-state.ts b/apps/web/test/integration/github-state.ts new file mode 100644 index 0000000..a0e5fa8 --- /dev/null +++ b/apps/web/test/integration/github-state.ts @@ -0,0 +1,60 @@ +/** + * State shared between the fake `@overlap/github` module and the tests. + * + * The fake lives in a bundle that Node loads natively (see `build-shims.ts`), + * while the test file is loaded by Vitest's module runner. Those are two + * different module graphs, so the state cannot be shared by importing it. + * It is parked on `globalThis` instead, which both graphs agree on because + * they run in the same worker process. + */ + +/** The subset of `CommitFile` the steps actually read. */ +export type FakeCommitFile = { + filename: string + status: string +} + +export type FakeGitHubCall = { + method: string + args: unknown[] +} + +export type GitHubFakeState = { + /** Every call the steps made, in order. */ + calls: FakeGitHubCall[] + /** What `getBranchFiles` returns, keyed by branch name. */ + branchFiles: Record + /** + * How long `getBranchFiles` takes. A non-zero value is what makes the + * sync-then-detect ordering test meaningful: if detection were merely + * racing sync rather than sequenced after it, this delay decides the race. + */ + getBranchFilesDelayMs: number + /** When set, `createCheckRun` throws this instead of returning an id. */ + createCheckRunError: unknown + /** Id handed out by the next successful `createCheckRun`. */ + nextCheckRunId: number +} + +const KEY = '__overlapGitHubFakeState__' + +export function createGitHubFakeState(): GitHubFakeState { + return { + calls: [], + branchFiles: {}, + getBranchFilesDelayMs: 0, + createCheckRunError: null, + nextCheckRunId: 5000, + } +} + +export function getGitHubFakeState(): GitHubFakeState { + const scope = globalThis as unknown as Record + scope[KEY] ??= createGitHubFakeState() + return scope[KEY] +} + +export function resetGitHubFakeState(): void { + const scope = globalThis as unknown as Record + scope[KEY] = createGitHubFakeState() +} diff --git a/apps/web/test/integration/harness.ts b/apps/web/test/integration/harness.ts new file mode 100644 index 0000000..61c7602 --- /dev/null +++ b/apps/web/test/integration/harness.ts @@ -0,0 +1,73 @@ +/** + * Test-side access to the doubles installed by `build-shims.ts`. + * + * The step bundle imports the database shim natively, from a path Vitest never + * sees, while this file reaches it through Vitest's module runner. Both must + * end up talking to the same Postgres instance. Vitest externalizes anything + * under a `node_modules` directory, and the shim is installed under one, so in + * practice both land on the same module - but the shim also parks its state on + * `globalThis`, so a second evaluation would still share one database. + */ + +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { PgliteDatabase } from 'drizzle-orm/pglite' +import type * as dbSchema from '@overlap/db/schema' +import { + getGitHubFakeState, + resetGitHubFakeState, + type GitHubFakeState, +} from './github-state.js' + +export type { GitHubFakeState } + +export type Schema = typeof dbSchema + +export type Harness = { + db: PgliteDatabase + schema: Schema + /** Empties every table. */ + reset(): Promise +} + +const shimUrl = pathToFileURL( + join( + resolve(import.meta.dirname, '../..'), + '.workflow-vitest', + 'node_modules', + '@overlap', + 'db', + 'index.mjs' + ) +).href + +let cached: Harness | undefined + +/** + * Boots (on first call) and returns the in-process Postgres the steps query. + * + * Loading it here, before any workflow starts, is what makes seeding possible: + * the step bundle would otherwise not be imported until the first step runs. + */ +export async function getHarness(): Promise { + if (!cached) { + const shim = (await import(/* @vite-ignore */ shimUrl)) as Record< + string, + unknown + > + cached = shim.__harness as Harness + } + return cached +} + +/** Clears the database and the recorded GitHub calls. */ +export async function resetHarness(): Promise { + const harness = await getHarness() + await harness.reset() + resetGitHubFakeState() + return harness +} + +export function github(): GitHubFakeState { + return getGitHubFakeState() +} diff --git a/apps/web/test/integration/patch-step-bundle.ts b/apps/web/test/integration/patch-step-bundle.ts new file mode 100644 index 0000000..edacc75 --- /dev/null +++ b/apps/web/test/integration/patch-step-bundle.ts @@ -0,0 +1,146 @@ +/** + * Works around a codegen bug in `@workflow/vitest` 4.0.18. + * + * The generated step bundle externalizes package imports rather than inlining + * them, and one of the SDK's own internals (`@workflow/builders`' serde + * checker) imports `builtin-modules`, whose package entry point is a `.json` + * file. The externalized import comes out as + * + * import builtinModules from ".../builtin-modules.json"; + * + * with no import attribute, which every Node release that supports JSON + * modules rejects with ERR_IMPORT_ATTRIBUTE_MISSING. The bundle then fails to + * load, and because the local world swallows the error as + * `[local world] Queue operation failed`, the only symptom is that every test + * times out after 60 seconds with no indication of the cause. + * + * This runs as a Vitest `setupFile`, which is the first point after the + * plugin's own global setup has written the bundles and before any test can + * trigger the lazy `import()` of them. The rewrite is idempotent and the file + * is replaced by rename, so concurrent workers cannot observe a partial write. + * + * It throws rather than no-oping when the bundle does not look the way it + * expects, so that an SDK upgrade produces an immediate, readable failure + * instead of reintroducing the silent 60-second timeout - in either direction: + * a bundle that is missing, or one that no longer contains the bad import + * (which is the signal that this file has done its job and should go). + * + * DELETE THIS FILE once `@workflow/vitest` emits the import attribute itself. + * `expectations` below records exactly what has to change for that to be safe. + */ + +import { readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** Extension-less imports are fine; only `.json` needs the attribute. */ +const JSON_IMPORT = + /(\bimport\s+[^;'"]*?\bfrom\s*)(["'])([^"']+\.json)\2(\s*;)/g + +const here = dirname(fileURLToPath(import.meta.url)) +const webRoot = resolve(here, '../..') +const outDir = join(webRoot, '.workflow-vitest') + +/** + * Bundles that must exist, and whether each is known to carry the bad import. + * `steps.mjs` is the one that does; `workflows.mjs` is checked opportunistically + * in case a future version moves the serde checker into it. + */ +const expectations = [ + { file: 'steps.mjs', mustContainJsonImport: true }, + { file: 'workflows.mjs', mustContainJsonImport: false }, +] + +async function installedVersion(): Promise { + try { + const manifest = await readFile( + join(webRoot, 'node_modules', '@workflow', 'vitest', 'package.json'), + 'utf8' + ) + return (JSON.parse(manifest) as { version?: string }).version ?? 'unknown' + } catch { + return 'unknown' + } +} + +async function fail(summary: string, detail: string): Promise { + throw new Error( + [ + `patch-step-bundle: ${summary}`, + '', + detail, + '', + `Installed @workflow/vitest: ${await installedVersion()}`, + `Bundle directory: ${relative(webRoot, outDir)}`, + `Expected import pattern: ${JSON_IMPORT.source}`, + '', + 'This file exists only to add `with { type: "json" }` to a JSON import', + 'that @workflow/vitest 4.0.18 emits without one. If the SDK has been', + 'upgraded and now emits the attribute itself, delete', + 'apps/web/test/integration/patch-step-bundle.ts and remove it from', + "vitest.integration.config.ts's setupFiles. If the SDK changed in some", + 'other way, update this file to match.', + ].join('\n') + ) +} + +async function patchBundle( + fileName: string, + mustContainJsonImport: boolean +): Promise { + const file = join(outDir, fileName) + + let source: string + try { + source = await readFile(file, 'utf8') + } catch (error) { + return fail( + `expected bundle ${fileName} does not exist`, + `Reading it failed with: ${(error as Error).message}\n` + + "The workflow plugin's global setup is supposed to have written it " + + 'before setup files run.' + ) + } + + const alreadyPatched = /\.json["']\s+with\s*\{\s*type:\s*["']json["']\s*\}/.test( + source + ) + const matches = source.match(JSON_IMPORT) ?? [] + + if (matches.length === 0) { + if (alreadyPatched) return // A previous worker got there first. + if (mustContainJsonImport) { + return fail( + `${fileName} no longer contains the unattributed JSON import this patch targets`, + 'Either the SDK now emits `with { type: "json" }` itself - in which ' + + 'case this file is obsolete and should be deleted - or it inlines ' + + 'the JSON instead of externalizing it, in which case the patch is ' + + 'no longer needed either. Verify by loading the bundle, then remove ' + + 'this file.' + ) + } + return + } + + const patched = source.replace( + JSON_IMPORT, + (_match, head, quote, specifier, tail) => + `${head}${quote}${specifier}${quote} with { type: "json" }${tail}` + ) + + if (patched === source) { + return fail( + `${fileName} contains a JSON import that could not be rewritten`, + `Matched ${matches.length} import(s) but the rewrite produced no change:\n` + + matches.map((match) => ` ${match}`).join('\n') + ) + } + + const temporary = `${file}.${process.pid}.tmp` + await writeFile(temporary, patched) + await rename(temporary, file) +} + +for (const { file, mustContainJsonImport } of expectations) { + await patchBundle(file, mustContainJsonImport) +} diff --git a/apps/web/test/integration/process-webhook.integration.test.ts b/apps/web/test/integration/process-webhook.integration.test.ts new file mode 100644 index 0000000..fabaeb6 --- /dev/null +++ b/apps/web/test/integration/process-webhook.integration.test.ts @@ -0,0 +1,298 @@ +/** + * Integration tests for `processWebhook`, run against the real workflow + * runtime via `@workflow/vitest`. + * + * These cover the two behaviours the whole migration rests on and that no + * unit test can reach, because a `"use workflow"` function throws when it is + * invoked directly: + * + * 1. Sync happens-before detect. The BullMQ system enqueued branch sync and + * overlap detection as two independent jobs and hoped a `delay: 5000` + * would keep them in order. The workflow replaces that with an awaited + * edge; this asserts the edge holds at runtime, and - more importantly - + * that detection reads the index the sync wrote. + * 2. Failure bookkeeping. `processWebhook` records the error on the + * `webhook_events` row and rethrows. If it did not, a failed delivery + * would leave no trace at all. + * + * Plus the check-run fan-out, which unions pull request ids so that N + * overlaps across M pull requests produce M calls rather than N*M. + */ + +import { and, eq } from 'drizzle-orm' +import { beforeEach, describe, expect, it } from 'vitest' +import { start } from 'workflow/api' +import { processWebhook } from '../../src/workflows/process-webhook' +import { + DEFAULT_BRANCH, + HEAD_SHA, + INSTALLATION_ID, + PUSHED_BRANCH, + REPO_FULL_NAME, + pushPayload, + seedBranchWithFiles, + seedOpenPullRequest, + seedRepository, + seedWebhookDelivery, + webhookEventRow, +} from './fixtures.js' +import { github, resetHarness, type Harness } from './harness.js' +import { countOf, firstIndexOf, stepLog } from './run-events.js' + +const OVERLAPPING_FILES = ['src/checkout.ts', 'src/shared.ts'] + +let harness: Harness + +beforeEach(async () => { + harness = await resetHarness() +}) + +describe('processWebhook - sync happens-before detect', () => { + it('detects overlaps from the index syncBranchFiles just wrote', async () => { + const { repositoryId, pushedBranchId } = await seedRepository(harness) + const otherBranchId = await seedBranchWithFiles( + harness, + repositoryId, + 'feature/pricing', + OVERLAPPING_FILES + ) + await seedWebhookDelivery(harness, 'delivery-order', pushPayload()) + + // The pushed branch starts with nothing indexed. `detectOverlaps` returns + // early for a branch with no tracked files, so if it ran before - or + // concurrently with - the sync it would find nothing. + const filesBefore = await harness.db.query.branchFiles.findMany({ + where: eq(harness.schema.branchFiles.branchId, pushedBranchId), + }) + expect(filesBefore).toHaveLength(0) + + github().branchFiles[PUSHED_BRANCH] = [ + { filename: 'src/checkout.ts', status: 'modified' }, + { filename: 'src/shared.ts', status: 'added' }, + ] + // Long enough that a detection merely racing the sync would lose. The old + // system's answer to this was `delay: 5000`. + github().getBranchFilesDelayMs = 250 + + const run = await start(processWebhook, ['delivery-order']) + await expect(run.returnValue).resolves.toEqual({ handled: true }) + await expect(run.status).resolves.toBe('completed') + + // The durable log is the runtime's own record of the ordering. + const log = await stepLog(run.runId) + const syncCompleted = firstIndexOf(log, 'step_completed', 'syncBranchFiles') + const detectCreated = firstIndexOf(log, 'step_created', 'detectOverlaps') + + expect(syncCompleted).toBeGreaterThanOrEqual(0) + expect(detectCreated).toBeGreaterThanOrEqual(0) + expect(syncCompleted).toBeLessThan(detectCreated) + + // GitHub was consulted exactly once, for the branch that was pushed. + const branchFileCalls = github().calls.filter( + (call) => call.method === 'getBranchFiles' + ) + expect(branchFileCalls).toHaveLength(1) + expect(branchFileCalls[0]?.args).toEqual([ + INSTALLATION_ID, + 'acme', + 'widgets', + PUSHED_BRANCH, + DEFAULT_BRANCH, + ]) + + // The index the sync wrote. + const filesAfter = await harness.db.query.branchFiles.findMany({ + where: eq(harness.schema.branchFiles.branchId, pushedBranchId), + }) + expect(filesAfter.map((file) => file.filePath).sort()).toEqual( + OVERLAPPING_FILES + ) + + // And the overlap that could only have come from reading it. + const detected = await harness.db.query.overlaps.findMany({ + with: { files: true }, + }) + expect(detected).toHaveLength(1) + expect(detected[0]).toMatchObject({ + repositoryId, + sourceBranchId: pushedBranchId, + targetBranchId: otherBranchId, + status: 'active', + fileCount: 2, + }) + expect(detected[0]?.files.map((file) => file.filePath).sort()).toEqual( + OVERLAPPING_FILES + ) + + const event = await webhookEventRow(harness, 'delivery-order') + expect(event?.processedAt).toBeInstanceOf(Date) + expect(event?.error).toBeNull() + }) + + it('records nothing for detection when the sync finds no files', async () => { + // The negative control for the assertion above: with an empty index the + // same run reaches `detectOverlaps` and finds nothing, which is what makes + // the overlap in the previous test evidence of the sync's output rather + // than of the seed data. + const { repositoryId, pushedBranchId } = await seedRepository(harness) + await seedBranchWithFiles( + harness, + repositoryId, + 'feature/pricing', + OVERLAPPING_FILES + ) + await seedWebhookDelivery(harness, 'delivery-empty', pushPayload()) + + github().branchFiles[PUSHED_BRANCH] = [] + + const run = await start(processWebhook, ['delivery-empty']) + await expect(run.returnValue).resolves.toEqual({ handled: true }) + + const log = await stepLog(run.runId) + expect(countOf(log, 'step_completed', 'syncBranchFiles')).toBe(1) + expect(countOf(log, 'step_completed', 'detectOverlaps')).toBe(1) + + const filesAfter = await harness.db.query.branchFiles.findMany({ + where: eq(harness.schema.branchFiles.branchId, pushedBranchId), + }) + expect(filesAfter).toHaveLength(0) + await expect(harness.db.query.overlaps.findMany()).resolves.toHaveLength(0) + }) +}) + +describe('processWebhook - failure bookkeeping', () => { + it('records the error on the delivery and still fails the run', async () => { + await seedRepository(harness) + // A payload GitHub would never send. `parsePayload` raises a FatalError, + // so the run fails on its first step with no retries. + await seedWebhookDelivery(harness, 'delivery-broken', { + ...pushPayload(), + installation: undefined, + }) + + const run = await start(processWebhook, ['delivery-broken']) + + await expect(run.returnValue).rejects.toThrow(/Malformed webhook payload/) + await expect(run.status).resolves.toBe('failed') + + // The failure path ran `markEventProcessed(deliveryId, message)`: the + // error is on the row, and `processedAt` is deliberately not set. + const event = await webhookEventRow(harness, 'delivery-broken') + expect(event?.error).toMatch(/Malformed webhook payload for delivery/) + expect(event?.processedAt).toBeNull() + + const log = await stepLog(run.runId) + expect(countOf(log, 'step_failed', 'loadEvent')).toBeGreaterThan(0) + expect(countOf(log, 'step_completed', 'markEventProcessed')).toBe(1) + }) + + it('records a GitHub failure raised deep in the run', async () => { + const { repositoryId, pushedBranchId } = await seedRepository(harness) + await seedBranchWithFiles( + harness, + repositoryId, + 'feature/pricing', + OVERLAPPING_FILES + ) + await seedOpenPullRequest(harness, repositoryId, pushedBranchId, 1) + await seedWebhookDelivery(harness, 'delivery-github-404', pushPayload()) + + github().branchFiles[PUSHED_BRANCH] = OVERLAPPING_FILES.map((filename) => ({ + filename, + status: 'modified', + })) + // A 4xx from GitHub is classified fatal by `classifyGitHubError`, so the + // step fails outright rather than being retried. + github().createCheckRunError = Object.assign(new Error('Not Found'), { + status: 404, + }) + + const run = await start(processWebhook, ['delivery-github-404']) + + await expect(run.returnValue).rejects.toThrow(/Not Found/) + await expect(run.status).resolves.toBe('failed') + + const event = await webhookEventRow(harness, 'delivery-github-404') + expect(event?.error).toMatch(/Not Found/) + expect(event?.processedAt).toBeNull() + + // The steps that ran before the failure are not rolled back - the overlap + // detection they performed is still on the row. + await expect(harness.db.query.overlaps.findMany()).resolves.toHaveLength(1) + }) +}) + +describe('processWebhook - check run fan-out', () => { + it('posts one check run per pull request, not per overlap per pull request', async () => { + const { repositoryId, pushedBranchId } = await seedRepository(harness) + await seedBranchWithFiles( + harness, + repositoryId, + 'feature/pricing', + OVERLAPPING_FILES + ) + await seedBranchWithFiles( + harness, + repositoryId, + 'feature/tax', + OVERLAPPING_FILES + ) + const prOne = await seedOpenPullRequest( + harness, + repositoryId, + pushedBranchId, + 101 + ) + const prTwo = await seedOpenPullRequest( + harness, + repositoryId, + pushedBranchId, + 102 + ) + await seedWebhookDelivery(harness, 'delivery-fanout', pushPayload()) + + github().branchFiles[PUSHED_BRANCH] = OVERLAPPING_FILES.map((filename) => ({ + filename, + status: 'modified', + })) + + const run = await start(processWebhook, ['delivery-fanout']) + await expect(run.returnValue).resolves.toEqual({ handled: true }) + + // Two overlaps (pricing, tax) x two open pull requests. Walking the + // notifications naively would post four check runs. + const overlaps = await harness.db.query.overlaps.findMany() + expect(overlaps).toHaveLength(2) + + const log = await stepLog(run.runId) + expect(countOf(log, 'step_created', 'postCheckRun')).toBe(2) + expect(countOf(log, 'step_created', 'sendPush')).toBe(2) + + const createCalls = github().calls.filter( + (call) => call.method === 'createCheckRun' + ) + expect(createCalls).toHaveLength(2) + // Both check runs went to the same repository at the pushed head sha. + for (const call of createCalls) { + expect(call.args[1]).toBe(REPO_FULL_NAME.split('/')[0]) + expect(call.args[3]).toBe(HEAD_SHA) + } + + // One alert row per pull request, both naming the same overlap - the id + // of the first notification that listed that pull request. + const alerts = await harness.db.query.prAlerts.findMany() + expect(alerts).toHaveLength(2) + expect(alerts.map((alert) => alert.pullRequestId).sort()).toEqual( + [prOne, prTwo].sort() + ) + expect(new Set(alerts.map((alert) => alert.overlapId)).size).toBe(1) + + const stillActive = await harness.db.query.overlaps.findMany({ + where: and( + eq(harness.schema.overlaps.repositoryId, repositoryId), + eq(harness.schema.overlaps.status, 'active') + ), + }) + expect(stillActive).toHaveLength(2) + }) +}) diff --git a/apps/web/test/integration/run-events.ts b/apps/web/test/integration/run-events.ts new file mode 100644 index 0000000..14b5391 --- /dev/null +++ b/apps/web/test/integration/run-events.ts @@ -0,0 +1,93 @@ +/** + * Reads a run's durable event log. + * + * This is the record the workflow runtime itself keeps, not something the test + * observes from the side, which is what makes it the right place to assert a + * happens-before edge between two steps. + */ + +import { getWorld } from 'workflow/runtime' + +export type StepLogEntry = { + /** Position in the run's event log. */ + index: number + eventType: string + /** As recorded, e.g. `step//./src/workflows/steps//syncBranchFiles`. */ + qualifiedName: string + /** Trailing segment of the above, e.g. `syncBranchFiles`. */ + stepName: string + correlationId: string +} + +type RawEvent = { + eventType: string + correlationId?: string + eventData?: { stepName?: string } +} + +/** + * Every `step_*` event of a run, in log order, with `stepName` filled in for + * the event types that only carry it on `step_created`. + */ +export async function stepLog(runId: string): Promise { + const world = getWorld() + const events: RawEvent[] = [] + + let cursor: string | null = null + do { + const page = await world.events.list({ + runId, + pagination: { limit: 1000, ...(cursor ? { cursor } : {}) }, + resolveData: 'none', + }) + events.push(...(page.data as unknown as RawEvent[])) + cursor = page.hasMore ? page.cursor : null + } while (cursor) + + const namesByCorrelationId = new Map() + for (const event of events) { + const name = event.eventData?.stepName + if (event.correlationId && name) { + namesByCorrelationId.set(event.correlationId, name) + } + } + + return events.flatMap((event, index) => { + if (!event.eventType.startsWith('step_')) return [] + const correlationId = event.correlationId ?? '' + const qualifiedName = + event.eventData?.stepName ?? + namesByCorrelationId.get(correlationId) ?? + '' + return [ + { + index, + eventType: event.eventType, + qualifiedName, + stepName: qualifiedName.split('//').pop() ?? qualifiedName, + correlationId, + }, + ] + }) +} + +/** Index in the step log of the first `eventType` for `stepName`, or -1. */ +export function firstIndexOf( + log: StepLogEntry[], + eventType: string, + stepName: string +): number { + return log.findIndex( + (entry) => entry.eventType === eventType && entry.stepName === stepName + ) +} + +export function countOf( + log: StepLogEntry[], + eventType: string, + stepName: string +): number { + return log.filter( + (entry) => entry.eventType === eventType && entry.stepName === stepName + ).length +} diff --git a/apps/web/test/integration/shared-shim.ts b/apps/web/test/integration/shared-shim.ts new file mode 100644 index 0000000..6b4dda4 --- /dev/null +++ b/apps/web/test/integration/shared-shim.ts @@ -0,0 +1,13 @@ +/** + * A loadable copy of `@overlap/shared` for the generated step bundle. + * + * Unlike the `@overlap/db` and `@overlap/github` shims this substitutes no + * behaviour at all - it re-exports the real module verbatim. It exists purely + * because the workspace package ships TypeScript sources whose relative + * imports carry `.js` extensions, which Node's own type stripping does not + * rewrite back to `.ts`; bundling it with esbuild produces something Node can + * import. The validation schemas, event constants and severity calculation the + * steps depend on are therefore the production ones. + */ + +export * from '@overlap/shared' diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index e1a1787..fdfc08c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -6,7 +6,8 @@ "baseUrl": ".", "paths": { "~/*": ["./src/*"] - } + }, + "plugins": [{ "name": "workflow" }] }, - "include": ["src/**/*"] + "include": ["src/**/*", "test/**/*"] } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 56414aa..4a64b09 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,28 +4,89 @@ import tsconfigPaths from 'vite-tsconfig-paths' import tailwindcss from '@tailwindcss/vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { nitro } from 'nitro/vite' - -const rawApiUrl = process.env.VITE_API_URL || process.env.API_URL || 'http://localhost:3001' -const apiUrl = rawApiUrl.startsWith('http') ? rawApiUrl : `https://${rawApiUrl}` +import { workflow } from 'workflow/vite' export default defineConfig({ server: { port: 3000, strictPort: true, - proxy: { - '/auth': apiUrl, - '/api': apiUrl, - }, }, plugins: [ tailwindcss(), tsconfigPaths(), - tanstackStart(), + workflow(), + tanstackStart({ + router: { + // `github-handler.ts` and its `__tests__` directory live under + // `src/routes/api/webhooks/` deliberately: `github-handler.ts` is + // imported by path from both `github.ts` (the actual route file) + // and `__tests__/verify-order.test.ts`, and moving it out of + // `routes/` would break that relative import. Neither exports a + // `Route`, so the file-based router would otherwise warn about both + // on every build and dev start. + routeFileIgnorePattern: '(github-handler\\.ts|^__tests__$)', + }, + }), nitro({ - preset: 'node_server', - routeRules: { - '/auth/**': { proxy: { to: `${apiUrl}/auth/**`, fetchOptions: { redirect: 'manual' } } }, - '/api/**': { proxy: `${apiUrl}/api/**` }, + preset: 'vercel', + // The whole app (SSR pages, every /api route, and the two cron + // triggers) is built into a single `__server` Vercel Function, so this + // is the only duration lever that exists for this deployment. + // + // The root vercel.json `functions` block does NOT reach this function: + // the Vercel "vercel" Nitro preset writes `.vc-config.json` straight + // from this `vercel.functions` option (see + // `generateFunctionFiles`/`resolveVercelRuntime` in + // `nitro/dist/_presets.mjs`), and it only reads the root vercel.json to + // check `bunVersion`. A `functions` glob in vercel.json would sit next + // to this file looking authoritative while silently doing nothing - + // confirmed by rebuilding with such a block present and finding + // `maxDuration` absent from the built `.vc-config.json`. + // + // 120s (Pro/Fluid default is 300s) is below the platform default, but + // it is NOT sized against the request path alone. Vercel Workflow + // steps ('use step' functions in apps/web/src/workflows/steps.ts) run + // as invocations of this same __server function - see "A step is one + // function invocation" in steps.ts - so this budget bounds every step, + // not just an SSR render, a Drizzle query, or a `start()` call that + // hands off to a workflow run and returns immediately. + // + // `syncRepository` is the step this budget was actually measured + // against, not a survey of every step - the other candidates below are + // unaudited, so the next person tuning this number should check them + // rather than assume this comment already covers them. + // + // `syncRepository` paginates octokit.repos.listBranches at per_page: + // 100 (packages/github/src/client.ts), which for a repository with a + // few thousand branches is on the order of 30-50 sequential GitHub API + // pages. Its database writes were previously one UPDATE-or-INSERT per + // branch, which alone could exceed 60s on a large repository; that + // per-branch loop is now two set-based statements (chunked - see the + // doc comment on `syncRepository` in steps.ts), so the dominant + // remaining cost is the sequential GitHub pagination itself. At normal + // GitHub latency that comfortably fits in 60s, but 120s is used + // instead to leave headroom for larger repositories and slower GitHub + // responses without moving all the way up to the 300s platform + // default, which would let a genuine hang - a wedged DB connection, an + // unbounded loop - run five times longer than necessary. + // + // Other steps share this same 120s budget but were not measured when + // it was chosen, and still do unbatched sequential DB round trips in a + // loop: + // - `detectOverlaps` (steps.ts, ~858-945) loops over every detected + // overlap doing 3-5 sequential round trips each. The loop is + // bounded by the number of non-stale branches sharing a file with + // the pushed branch, which fans out across every active branch + // when a push touches a common file like `package.json`. + // - `syncInstallation` (steps.ts, ~543-560) and + // `applyInstallationRepositories` (steps.ts, ~613-643) each do one + // sequential upsert per repository in the installation payload. + // None of these are currently known to fail; they just have not been + // audited against this number the way `syncRepository` has. + vercel: { + functions: { + maxDuration: 120, + }, }, }), viteReact(), diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..9a2f0b7 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + include: ['src/**/*.test.ts'], + // Integration tests live in `test/` and need the workflow runtime that + // only `vitest.integration.config.ts` sets up. Excluded by name as well + // as by directory so a stray file cannot end up in both suites. + exclude: ['**/node_modules/**', '**/*.integration.test.ts'], + environment: 'node', + env: { + DATABASE_URL: 'postgresql://test:test@localhost/test', + }, + }, +}) diff --git a/apps/web/vitest.integration.config.ts b/apps/web/vitest.integration.config.ts new file mode 100644 index 0000000..81beefb --- /dev/null +++ b/apps/web/vitest.integration.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from 'vitest/config' +import { workflow } from '@workflow/vitest' + +/** + * Integration tests for the durable workflow. + * + * `workflow()` compiles the `"use workflow"` / `"use step"` directives, + * bundles the workflow and step entry points, and runs them against an + * in-process Local World - so `start()` here exercises the real runtime: + * real step dispatch, real event log, real failure semantics. + * + * It is a separate config from `vitest.config.ts` on purpose. The plugin + * builds those bundles before the suite starts, which the unit tests neither + * need nor should pay for, and the two suites are kept apart by file name: + * `*.integration.test.ts` here, `src/**\/*.test.ts` there. + */ +export default defineConfig({ + plugins: [workflow()], + test: { + include: ['test/**/*.integration.test.ts'], + environment: 'node', + // A run boots PGlite, applies migrations and executes a chain of steps. + testTimeout: 60_000, + hookTimeout: 60_000, + // Installs the `@overlap/db` / `@overlap/github` doubles the step bundle + // resolves at runtime. Runs alongside the plugin's own global setup. + globalSetup: ['./test/integration/build-shims.ts'], + // Repairs a JSON import in the SDK's generated bundle. Must run after the + // plugin's global setup has written it, which is why it is a setup file. + setupFiles: ['./test/integration/patch-step-bundle.ts'], + env: { + // `packages/db/src/client.ts` throws at import time without this. The + // step bundle never reaches that file (see `build-shims.ts`), but other + // modules in the graph import `@overlap/db` normally. + DATABASE_URL: 'postgresql://test:test@localhost/test', + }, + }, +}) diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile deleted file mode 100644 index e90961c..0000000 --- a/apps/worker/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM node:20-slim - -# Install pnpm -RUN npm install -g pnpm - -WORKDIR /app - -# Copy workspace config files -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ - -# Copy packages and app -COPY packages/ ./packages/ -COPY apps/worker/ ./apps/worker/ - -# Install dependencies -RUN pnpm install --frozen-lockfile - -# Build worker (tsup bundles workspace dependencies) -RUN pnpm --filter @overlap/worker build - -# Start the worker -CMD ["node", "apps/worker/dist/index.js"] diff --git a/apps/worker/package.json b/apps/worker/package.json deleted file mode 100644 index e0191f3..0000000 --- a/apps/worker/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@overlap/worker", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx watch --env-file=../../.env src/index.ts", - "build": "tsup", - "start": "node dist/index.js", - "typecheck": "tsc --noEmit", - "lint": "eslint src/" - }, - "dependencies": { - "@overlap/db": "workspace:*", - "@overlap/github": "workspace:*", - "@overlap/shared": "workspace:*", - "bullmq": "^5.25.0", - "drizzle-orm": "^0.38.0", - "ioredis": "^5.4.0", - "minimatch": "^10.0.0", - "web-push": "^3.6.7" - }, - "devDependencies": { - "@types/node": "^20.11.0", - "@types/web-push": "^3.6.4", - "tsup": "^8.0.0", - "tsx": "^4.19.0", - "typescript": "^5.7.0" - } -} diff --git a/apps/worker/railway.json b/apps/worker/railway.json deleted file mode 100644 index ab0f45d..0000000 --- a/apps/worker/railway.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "apps/worker/Dockerfile" - }, - "deploy": { - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 3 - } -} diff --git a/apps/worker/railway.toml b/apps/worker/railway.toml deleted file mode 100644 index 9c5c7eb..0000000 --- a/apps/worker/railway.toml +++ /dev/null @@ -1,8 +0,0 @@ -[build] -builder = "nixpacks" -buildCommand = "cd ../.. && pnpm install && pnpm --filter @overlap/worker build" - -[deploy] -startCommand = "node dist/index.js" -restartPolicyType = "ON_FAILURE" -restartPolicyMaxRetries = 3 diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts deleted file mode 100644 index 239bdbc..0000000 --- a/apps/worker/src/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Worker } from 'bullmq' -import { Redis } from 'ioredis' -import { QUEUE_NAMES, RATE_LIMITS } from '@overlap/shared' -import { webhookEventsProcessor } from './processors/webhook-events.js' -import { branchSyncProcessor } from './processors/branch-sync.js' -import { overlapDetectionProcessor } from './processors/overlap-detection.js' -import { githubFeedbackProcessor } from './processors/github-feedback.js' -import { maintenanceProcessor } from './processors/maintenance.js' -import { pushNotificationProcessor } from './processors/push-notification.js' - -const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' - -const connection = new Redis(redisUrl, { - maxRetriesPerRequest: null, -}) - -console.log('Starting Overlap workers...') - -// Create workers with rate limiting -const workers: Worker[] = [] - -// Webhook Events Worker -workers.push( - new Worker(QUEUE_NAMES.WEBHOOK_EVENTS, webhookEventsProcessor, { - connection, - concurrency: 10, - limiter: RATE_LIMITS[QUEUE_NAMES.WEBHOOK_EVENTS], - }) -) - -// Branch Sync Worker -workers.push( - new Worker(QUEUE_NAMES.BRANCH_SYNC, branchSyncProcessor, { - connection, - concurrency: 5, - limiter: RATE_LIMITS[QUEUE_NAMES.BRANCH_SYNC], - }) -) - -// Overlap Detection Worker -workers.push( - new Worker(QUEUE_NAMES.OVERLAP_DETECTION, overlapDetectionProcessor, { - connection, - concurrency: 10, - limiter: RATE_LIMITS[QUEUE_NAMES.OVERLAP_DETECTION], - }) -) - -// GitHub Feedback Worker -workers.push( - new Worker(QUEUE_NAMES.GITHUB_FEEDBACK, githubFeedbackProcessor, { - connection, - concurrency: 2, - limiter: RATE_LIMITS[QUEUE_NAMES.GITHUB_FEEDBACK], - }) -) - -// Maintenance Worker -workers.push( - new Worker(QUEUE_NAMES.MAINTENANCE, maintenanceProcessor, { - connection, - concurrency: 1, - limiter: RATE_LIMITS[QUEUE_NAMES.MAINTENANCE], - }) -) - -// Push Notification Worker -workers.push( - new Worker(QUEUE_NAMES.PUSH_NOTIFICATION, pushNotificationProcessor, { - connection, - concurrency: 5, - limiter: RATE_LIMITS[QUEUE_NAMES.PUSH_NOTIFICATION], - }) -) - -// Setup event handlers for all workers -for (const worker of workers) { - worker.on('completed', (job) => { - console.log(`[${worker.name}] Job ${job.id} completed`) - }) - - worker.on('failed', (job, err) => { - console.error(`[${worker.name}] Job ${job?.id} failed:`, err.message) - }) - - worker.on('error', (err) => { - console.error(`[${worker.name}] Worker error:`, err.message) - }) -} - -console.log(`Started ${workers.length} workers`) - -// Graceful shutdown -const shutdown = async () => { - console.log('Shutting down workers...') - - await Promise.all(workers.map((w) => w.close())) - await connection.quit() - - console.log('Workers shut down') - process.exit(0) -} - -process.on('SIGINT', shutdown) -process.on('SIGTERM', shutdown) diff --git a/apps/worker/src/processors/branch-sync.ts b/apps/worker/src/processors/branch-sync.ts deleted file mode 100644 index f7604fc..0000000 --- a/apps/worker/src/processors/branch-sync.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Job } from 'bullmq' -import { db, branches, branchFiles, repositories, repositorySettings } from '@overlap/db' -import { eq, and, inArray } from 'drizzle-orm' -import type { BranchSyncJob } from '@overlap/shared' -import { DEFAULT_SETTINGS } from '@overlap/shared' -import { getGitHubClient, type CommitFile } from '@overlap/github' -import { minimatch } from 'minimatch' - -export async function branchSyncProcessor(job: Job) { - const { repositoryId, branchName, sha, installationId } = job.data - - console.log(`Syncing branch: ${branchName} (${sha.slice(0, 7)})`) - - // Get repository info - const repo = await db.query.repositories.findFirst({ - where: eq(repositories.id, repositoryId), - with: { settings: true }, - }) - - if (!repo) { - throw new Error(`Repository not found: ${repositoryId}`) - } - - // Get branch record - const branch = await db.query.branches.findFirst({ - where: and( - eq(branches.repositoryId, repositoryId), - eq(branches.name, branchName) - ), - }) - - if (!branch) { - throw new Error(`Branch not found: ${branchName}`) - } - - // Get ignored paths - const ignoredPaths = repo.settings?.ignoredPaths ?? DEFAULT_SETTINGS.IGNORED_PATHS - - // Get GitHub client and fetch changed files - const github = getGitHubClient() - const [owner, repoName] = repo.fullName.split('/') - - let changedFiles: CommitFile[] - - try { - // Compare branch to default branch to get all changed files - changedFiles = await github.getBranchFiles( - installationId, - owner, - repoName, - branchName, - repo.defaultBranch - ) - } catch (error) { - console.error(`Failed to fetch branch files: ${error}`) - changedFiles = [] - } - - // Filter out ignored paths - const filteredFiles = changedFiles.filter((file) => { - return !ignoredPaths.some((pattern) => minimatch(file.filename, pattern)) - }) - - console.log(`Found ${filteredFiles.length} changed files (${changedFiles.length} before filtering)`) - - // Update branch files - // First, delete existing files for this branch - await db.delete(branchFiles).where(eq(branchFiles.branchId, branch.id)) - - // Insert new files - if (filteredFiles.length > 0) { - await db.insert(branchFiles).values( - filteredFiles.map((file) => ({ - branchId: branch.id, - filePath: file.filename, - changeType: mapChangeType(file.status), - })) - ) - } - - // Update branch sha and last seen - await db - .update(branches) - .set({ - sha, - lastSeenAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(branches.id, branch.id)) - - // Update repository last synced - await db - .update(repositories) - .set({ lastSyncedAt: new Date(), updatedAt: new Date() }) - .where(eq(repositories.id, repositoryId)) - - console.log(`Branch sync complete: ${branchName}`) - - return { filesIndexed: filteredFiles.length } -} - -function mapChangeType(status: string): 'added' | 'modified' | 'deleted' | 'renamed' { - switch (status) { - case 'added': - return 'added' - case 'removed': - return 'deleted' - case 'renamed': - return 'renamed' - default: - return 'modified' - } -} diff --git a/apps/worker/src/processors/github-feedback.ts b/apps/worker/src/processors/github-feedback.ts deleted file mode 100644 index 69f4268..0000000 --- a/apps/worker/src/processors/github-feedback.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { Job } from 'bullmq' -import { db, overlaps, overlapFiles, branches, pullRequests, prAlerts, repositories, githubAppInstallations } from '@overlap/db' -import { eq, and, inArray } from 'drizzle-orm' -import type { GitHubFeedbackJob } from '@overlap/shared' -import { getGitHubClient, formatCheckRunSummary } from '@overlap/github' - -export async function githubFeedbackProcessor(job: Job) { - const { repositoryId, pullRequestId, overlapId, alertType } = job.data - - console.log(`Sending GitHub feedback for PR: ${pullRequestId}, overlap: ${overlapId}`) - - // Get the pull request - const pr = await db.query.pullRequests.findFirst({ - where: eq(pullRequests.id, pullRequestId), - with: { - branch: true, - repository: { - with: { - installation: true, - }, - }, - }, - }) - - if (!pr) { - throw new Error(`Pull request not found: ${pullRequestId}`) - } - - // Get the overlap with all files - const overlap = await db.query.overlaps.findFirst({ - where: eq(overlaps.id, overlapId), - with: { - files: true, - sourceBranch: true, - targetBranch: true, - }, - }) - - if (!overlap) { - throw new Error(`Overlap not found: ${overlapId}`) - } - - // Get all active overlaps for this branch (not just the one that triggered) - const allOverlaps = await db.query.overlaps.findMany({ - where: and( - eq(overlaps.repositoryId, repositoryId), - eq(overlaps.status, 'active') - ), - with: { - files: true, - sourceBranch: true, - targetBranch: true, - }, - }) - - // Filter to overlaps involving this branch - const branchOverlaps = allOverlaps.filter( - (o) => o.sourceBranchId === pr.branch.id || o.targetBranchId === pr.branch.id - ) - - if (branchOverlaps.length === 0) { - console.log('No active overlaps for this branch') - return { checkRun: false } - } - - // Check for existing alert (deduplication) - const existingAlert = await db.query.prAlerts.findFirst({ - where: and( - eq(prAlerts.pullRequestId, pullRequestId), - eq(prAlerts.overlapId, overlapId) - ), - }) - - const github = getGitHubClient() - const [owner, repoName] = pr.repository.fullName.split('/') - const installationId = pr.repository.installation.installationId - - let checkRunId: number | null = existingAlert?.checkRunId ?? null - - // Format overlap data for display - const overlapData = branchOverlaps.map((o) => { - const otherBranch = - o.sourceBranchId === pr.branch.id ? o.targetBranch : o.sourceBranch - - return { - branchName: otherBranch.name, - files: o.files.map((f) => f.filePath), - fileCount: o.fileCount, - severity: o.severity as 'low' | 'medium' | 'high' | 'critical', - } - }) - - // Create check run (no PR comments — GitHub already shows conflicts) - const { conclusion, title, summary } = formatCheckRunSummary(overlapData) - - try { - checkRunId = await github.createCheckRun( - installationId, - owner, - repoName, - pr.branch.sha, - 'Overlap Detection', - conclusion, - title, - summary - ) - console.log(`Check run created: ${checkRunId}`) - } catch (error) { - console.error('Failed to create check run:', error) - } - - // Record the alert - if (existingAlert) { - await db - .update(prAlerts) - .set({ checkRunId }) - .where(eq(prAlerts.id, existingAlert.id)) - } else { - await db.insert(prAlerts).values({ - pullRequestId, - overlapId, - alertType: 'check_run', - checkRunId, - }) - } - - return { checkRun: checkRunId !== null } -} diff --git a/apps/worker/src/processors/maintenance.ts b/apps/worker/src/processors/maintenance.ts deleted file mode 100644 index f231252..0000000 --- a/apps/worker/src/processors/maintenance.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { Job } from 'bullmq' -import { db, branches, branchFiles, overlaps, webhookEvents, repositories, repositorySettings } from '@overlap/db' -import { eq, and, lt, inArray, sql } from 'drizzle-orm' -import type { MaintenanceJob } from '@overlap/shared' -import { DEFAULT_SETTINGS } from '@overlap/shared' -import { getGitHubClient } from '@overlap/github' - -export async function maintenanceProcessor(job: Job) { - const { type, repositoryId } = job.data - - console.log(`Running maintenance task: ${type}${repositoryId ? ` for ${repositoryId}` : ''}`) - - switch (type) { - case 'prune_branches': - return await pruneStaleBranches(repositoryId) - - case 'cleanup_events': - return await cleanupOldEvents() - - case 'sync_repository': - if (!repositoryId) { - throw new Error('repositoryId required for sync_repository') - } - return await syncRepository(repositoryId) - - default: - throw new Error(`Unknown maintenance type: ${type}`) - } -} - -async function pruneStaleBranches(repositoryId?: string) { - let prunedCount = 0 - - // Get repositories to process - const repos = repositoryId - ? await db.query.repositories.findMany({ - where: eq(repositories.id, repositoryId), - with: { settings: true }, - }) - : await db.query.repositories.findMany({ - where: eq(repositories.isActive, true), - with: { settings: true }, - }) - - for (const repo of repos) { - const pruningDays = repo.settings?.pruningDays ?? DEFAULT_SETTINGS.PRUNING_DAYS - const staleDate = new Date() - staleDate.setDate(staleDate.getDate() - pruningDays) - - // Find stale branches (non-default, not seen recently) - const staleBranches = await db.query.branches.findMany({ - where: and( - eq(branches.repositoryId, repo.id), - eq(branches.isDefault, false), - lt(branches.lastSeenAt, staleDate) - ), - }) - - if (staleBranches.length === 0) continue - - const staleBranchIds = staleBranches.map((b) => b.id) - - // Delete branch files - await db.delete(branchFiles).where(inArray(branchFiles.branchId, staleBranchIds)) - - // Update overlaps involving these branches to resolved - await db - .update(overlaps) - .set({ - status: 'resolved', - resolvedAt: new Date(), - updatedAt: new Date(), - }) - .where( - sql`(${overlaps.sourceBranchId} IN (${sql.join(staleBranchIds, sql`, `)}) OR ${overlaps.targetBranchId} IN (${sql.join(staleBranchIds, sql`, `)}))` - ) - - // Delete the branches - await db.delete(branches).where(inArray(branches.id, staleBranchIds)) - - prunedCount += staleBranches.length - console.log(`Pruned ${staleBranches.length} stale branches from ${repo.fullName}`) - } - - return { prunedBranches: prunedCount } -} - -async function cleanupOldEvents() { - // Delete webhook events older than 7 days - const cutoffDate = new Date() - cutoffDate.setDate(cutoffDate.getDate() - 7) - - const result = await db - .delete(webhookEvents) - .where(lt(webhookEvents.createdAt, cutoffDate)) - - console.log(`Cleaned up old webhook events`) - return { cleaned: true } -} - -async function syncRepository(repositoryId: string) { - const repo = await db.query.repositories.findFirst({ - where: eq(repositories.id, repositoryId), - with: { installation: true }, - }) - - if (!repo) { - throw new Error(`Repository not found: ${repositoryId}`) - } - - const github = getGitHubClient() - const [owner, repoName] = repo.fullName.split('/') - - // Fetch all branches from GitHub - const remoteBranches = await github.getBranches( - repo.installation.installationId, - owner, - repoName - ) - - // Get current local branches - const localBranches = await db.query.branches.findMany({ - where: eq(branches.repositoryId, repositoryId), - }) - - const localBranchMap = new Map(localBranches.map((b) => [b.name, b])) - const remoteBranchNames = new Set(remoteBranches.map((b) => b.name)) - - // Find branches to add or update - let addedCount = 0 - let updatedCount = 0 - - for (const remote of remoteBranches) { - const local = localBranchMap.get(remote.name) - const isDefault = remote.name === repo.defaultBranch - - if (local) { - // Update if SHA changed - if (local.sha !== remote.sha) { - await db - .update(branches) - .set({ - sha: remote.sha, - lastSeenAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(branches.id, local.id)) - updatedCount++ - } - } else { - // Add new branch - await db.insert(branches).values({ - repositoryId, - name: remote.name, - sha: remote.sha, - isDefault, - lastSeenAt: new Date(), - }) - addedCount++ - } - } - - // Find branches that no longer exist on remote - const deletedCount = localBranches.filter( - (b) => !remoteBranchNames.has(b.name) - ).length - - // Mark missing branches as stale (they'll be pruned later) - for (const local of localBranches) { - if (!remoteBranchNames.has(local.name)) { - await db - .update(branches) - .set({ - lastSeenAt: new Date(0), // Epoch = very old - updatedAt: new Date(), - }) - .where(eq(branches.id, local.id)) - } - } - - // Update repo sync timestamp - await db - .update(repositories) - .set({ lastSyncedAt: new Date(), updatedAt: new Date() }) - .where(eq(repositories.id, repositoryId)) - - console.log(`Synced repository: added ${addedCount}, updated ${updatedCount}, marked ${deletedCount} for deletion`) - - return { added: addedCount, updated: updatedCount, markedForDeletion: deletedCount } -} diff --git a/apps/worker/src/processors/overlap-detection.ts b/apps/worker/src/processors/overlap-detection.ts deleted file mode 100644 index 1714815..0000000 --- a/apps/worker/src/processors/overlap-detection.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type { Job } from 'bullmq' -import { db, branches, branchFiles, overlaps, overlapFiles, repositories, repositorySettings, pullRequests } from '@overlap/db' -import { eq, and, ne, sql, inArray, gt } from 'drizzle-orm' -import type { OverlapDetectionJob } from '@overlap/shared' -import { calculateSeverity, DEFAULT_SETTINGS, QUEUE_NAMES } from '@overlap/shared' -import { Queue } from 'bullmq' -import { Redis } from 'ioredis' - -// Get queues for adding feedback and notification jobs -const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' -const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }) -const githubFeedbackQueue = new Queue(QUEUE_NAMES.GITHUB_FEEDBACK, { connection }) -const pushNotificationQueue = new Queue(QUEUE_NAMES.PUSH_NOTIFICATION, { connection }) - -export async function overlapDetectionProcessor(job: Job) { - const { repositoryId, branchId, triggeredBy } = job.data - - console.log(`Detecting overlaps for branch: ${branchId} (triggered by: ${triggeredBy})`) - - // Get the branch and its files - const branch = await db.query.branches.findFirst({ - where: eq(branches.id, branchId), - with: { files: true, repository: { with: { settings: true } } }, - }) - - if (!branch) { - throw new Error(`Branch not found: ${branchId}`) - } - - // Skip if this is the default branch - if (branch.isDefault) { - console.log('Skipping default branch') - return { overlapsFound: 0 } - } - - // Skip if branch has no files - if (branch.files.length === 0) { - console.log('Branch has no tracked files') - return { overlapsFound: 0 } - } - - // Get settings - const pruningDays = branch.repository.settings?.pruningDays ?? DEFAULT_SETTINGS.PRUNING_DAYS - - // Calculate stale date threshold - const staleDate = new Date() - staleDate.setDate(staleDate.getDate() - pruningDays) - - // Get all other active branches in the same repository - const otherBranches = await db.query.branches.findMany({ - where: and( - eq(branches.repositoryId, repositoryId), - ne(branches.id, branchId), - eq(branches.isDefault, false), - gt(branches.lastSeenAt, staleDate) - ), - with: { files: true }, - }) - - console.log(`Comparing against ${otherBranches.length} other branches`) - - const branchFilePaths = new Set(branch.files.map((f) => f.filePath)) - const detectedOverlaps: Array<{ - targetBranchId: string - files: Array<{ - filePath: string - sourceChangeType: string - targetChangeType: string - }> - }> = [] - - // Find overlapping files with each other branch - for (const otherBranch of otherBranches) { - const overlappingFiles: Array<{ - filePath: string - sourceChangeType: string - targetChangeType: string - }> = [] - - for (const otherFile of otherBranch.files) { - if (branchFilePaths.has(otherFile.filePath)) { - const sourceFile = branch.files.find((f) => f.filePath === otherFile.filePath) - if (sourceFile) { - overlappingFiles.push({ - filePath: otherFile.filePath, - sourceChangeType: sourceFile.changeType, - targetChangeType: otherFile.changeType, - }) - } - } - } - - if (overlappingFiles.length > 0) { - detectedOverlaps.push({ - targetBranchId: otherBranch.id, - files: overlappingFiles, - }) - } - } - - console.log(`Found ${detectedOverlaps.length} overlapping branches`) - - // Process each detected overlap - for (const detected of detectedOverlaps) { - const severity = calculateSeverity(detected.files.length) - - // Check if overlap already exists (in either direction) - const existingOverlap = await db.query.overlaps.findFirst({ - where: sql` - ${overlaps.repositoryId} = ${repositoryId} - AND ( - (${overlaps.sourceBranchId} = ${branchId} AND ${overlaps.targetBranchId} = ${detected.targetBranchId}) - OR (${overlaps.sourceBranchId} = ${detected.targetBranchId} AND ${overlaps.targetBranchId} = ${branchId}) - ) - `, - }) - - let overlapId: string - let isNew = false - let severityIncreased = false - let wasReactivated = false - - if (existingOverlap) { - // Update existing overlap - const oldSeverity = existingOverlap.severity - severityIncreased = compareSeverity(severity, oldSeverity as typeof severity) > 0 - wasReactivated = existingOverlap.status === 'resolved' || existingOverlap.status === 'ignored' - - await db - .update(overlaps) - .set({ - fileCount: detected.files.length, - severity, - status: 'active', - resolvedAt: null, - updatedAt: new Date(), - }) - .where(eq(overlaps.id, existingOverlap.id)) - - overlapId = existingOverlap.id - - // Delete old overlap files and insert new ones - await db.delete(overlapFiles).where(eq(overlapFiles.overlapId, overlapId)) - } else { - // Create new overlap - isNew = true - const [newOverlap] = await db - .insert(overlaps) - .values({ - repositoryId, - sourceBranchId: branchId, - targetBranchId: detected.targetBranchId, - fileCount: detected.files.length, - severity, - status: 'active', - detectedAt: new Date(), - }) - .returning() - - overlapId = newOverlap.id - } - - // Insert overlap files - await db.insert(overlapFiles).values( - detected.files.map((f) => ({ - overlapId, - filePath: f.filePath, - sourceChangeType: f.sourceChangeType, - targetChangeType: f.targetChangeType, - })) - ) - - // Check if we should send notifications - // Notify on: new overlaps, severity increases, or reactivated overlaps - // (previously resolved/ignored but the file was edited again) - const shouldNotify = - (isNew && branch.repository.settings?.notifyOnNewOverlap !== false) || - (severityIncreased && branch.repository.settings?.notifyOnSeverityIncrease !== false) || - wasReactivated - - if (shouldNotify) { - // Use a timestamp suffix so reactivated overlaps aren't deduplicated by BullMQ - const jobSuffix = wasReactivated ? `-${Date.now()}` : '' - - // Find any open PRs for this branch - const openPRs = await db.query.pullRequests.findMany({ - where: and( - eq(pullRequests.branchId, branchId), - eq(pullRequests.state, 'open') - ), - }) - - for (const pr of openPRs) { - await githubFeedbackQueue.add( - 'feedback', - { - repositoryId, - pullRequestId: pr.id, - overlapId, - alertType: 'check_run', - }, - { - jobId: `${pr.id}-${overlapId}${jobSuffix}`, - } - ) - } - - // Send push notification to the developer on the OTHER branch - // If A pushed and overlaps with B, notify B's developer - await pushNotificationQueue.add( - 'notify', - { - repositoryId, - overlapId, - targetBranchId: detected.targetBranchId, - }, - { - jobId: `push-${overlapId}-${detected.targetBranchId}${jobSuffix}`, - } - ) - } - } - - // Check for resolved overlaps (overlaps that no longer have overlapping files) - const currentOverlaps = await db.query.overlaps.findMany({ - where: and( - eq(overlaps.repositoryId, repositoryId), - sql`(${overlaps.sourceBranchId} = ${branchId} OR ${overlaps.targetBranchId} = ${branchId})`, - eq(overlaps.status, 'active') - ), - }) - - const activeTargetIds = new Set(detectedOverlaps.map((o) => o.targetBranchId)) - - for (const overlap of currentOverlaps) { - const otherBranchId = - overlap.sourceBranchId === branchId ? overlap.targetBranchId : overlap.sourceBranchId - - if (!activeTargetIds.has(otherBranchId)) { - // This overlap is no longer active - await db - .update(overlaps) - .set({ - status: 'resolved', - resolvedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(overlaps.id, overlap.id)) - - console.log(`Overlap resolved: ${overlap.id}`) - } - } - - return { overlapsFound: detectedOverlaps.length } -} - -function compareSeverity( - a: 'low' | 'medium' | 'high' | 'critical', - b: 'low' | 'medium' | 'high' | 'critical' -): number { - const order = { low: 0, medium: 1, high: 2, critical: 3 } - return order[a] - order[b] -} diff --git a/apps/worker/src/processors/push-notification.ts b/apps/worker/src/processors/push-notification.ts deleted file mode 100644 index 30744eb..0000000 --- a/apps/worker/src/processors/push-notification.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Job } from 'bullmq' -import { db, overlaps, branches, users, pushSubscriptions } from '@overlap/db' -import { eq } from 'drizzle-orm' -import type { PushNotificationJob } from '@overlap/shared' -import webpush from 'web-push' - -const APP_URL = process.env.APP_URL || 'http://localhost:3000' - -// Configure VAPID -const vapidPublicKey = process.env.VAPID_PUBLIC_KEY -const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY -const vapidSubject = process.env.VAPID_SUBJECT - -if (vapidPublicKey && vapidPrivateKey && vapidSubject) { - webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey) -} - -export async function pushNotificationProcessor(job: Job) { - const { repositoryId, overlapId, targetBranchId } = job.data - - if (!vapidPublicKey || !vapidPrivateKey) { - console.log('VAPID keys not configured, skipping push notification') - return { sent: 0 } - } - - // Get the overlap with branch names - const overlap = await db.query.overlaps.findFirst({ - where: eq(overlaps.id, overlapId), - with: { - sourceBranch: true, - targetBranch: true, - files: true, - }, - }) - - if (!overlap) { - console.log(`Overlap not found: ${overlapId}`) - return { sent: 0 } - } - - // Get the target branch's last pusher - const targetBranch = await db.query.branches.findFirst({ - where: eq(branches.id, targetBranchId), - }) - - if (!targetBranch?.lastPusherGithubId) { - console.log(`No pusher info for branch: ${targetBranchId}`) - return { sent: 0 } - } - - // Find the user matching that GitHub ID - const user = await db.query.users.findFirst({ - where: eq(users.githubId, targetBranch.lastPusherGithubId), - }) - - if (!user) { - console.log(`User not found for githubId: ${targetBranch.lastPusherGithubId}`) - return { sent: 0 } - } - - // Get user's push subscriptions - const subscriptions = await db.query.pushSubscriptions.findMany({ - where: eq(pushSubscriptions.userId, user.id), - }) - - if (subscriptions.length === 0) { - console.log(`No push subscriptions for user: ${user.id}`) - return { sent: 0 } - } - - // Build notification payload - // Orient so the recipient's branch appears first (same as UI auto-orient) - const isRecipientSource = overlap.sourceBranchId === targetBranchId - const yourBranch = isRecipientSource ? overlap.sourceBranch.name : overlap.targetBranch.name - const otherBranch = isRecipientSource ? overlap.targetBranch.name : overlap.sourceBranch.name - const fileCount = overlap.files.length - - const payload = JSON.stringify({ - title: `Overlap Detected · ${fileCount} file${fileCount !== 1 ? 's' : ''}`, - body: `${yourBranch} ↔ ${otherBranch}`, - url: `${APP_URL}/repositories/${repositoryId}`, - tag: `overlap-${overlapId}`, - }) - - let sent = 0 - - for (const sub of subscriptions) { - try { - await webpush.sendNotification( - { - endpoint: sub.endpoint, - keys: { - p256dh: sub.p256dh, - auth: sub.auth, - }, - }, - payload - ) - sent++ - } catch (err: any) { - // Remove expired/invalid subscriptions - if (err.statusCode === 404 || err.statusCode === 410) { - await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id)) - console.log(`Removed expired subscription: ${sub.id}`) - } else { - console.error(`Failed to send push to ${sub.endpoint}:`, err.message) - } - } - } - - console.log(`Sent ${sent}/${subscriptions.length} push notifications for overlap ${overlapId}`) - return { sent } -} diff --git a/apps/worker/src/processors/webhook-events.ts b/apps/worker/src/processors/webhook-events.ts deleted file mode 100644 index 49c5a8b..0000000 --- a/apps/worker/src/processors/webhook-events.ts +++ /dev/null @@ -1,432 +0,0 @@ -import type { Job } from 'bullmq' -import { db, webhookEvents, repositories, branches, githubAppInstallations, pullRequests, users, userInstallations } from '@overlap/db' -import { eq, and } from 'drizzle-orm' -import type { WebhookEventJob, PushEvent, PullRequestEvent, InstallationEvent } from '@overlap/shared' -import { GITHUB_EVENTS, PR_ACTIONS, pushEventSchema, pullRequestEventSchema, installationEventSchema } from '@overlap/shared' -import { extractBranchFromRef, isBranchDeletion, isBranchCreation } from '@overlap/github' -import { Queue } from 'bullmq' -import { Redis } from 'ioredis' -import { QUEUE_NAMES } from '@overlap/shared' - -// Get queues for adding jobs -const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' -const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }) -const branchSyncQueue = new Queue(QUEUE_NAMES.BRANCH_SYNC, { connection }) -const overlapDetectionQueue = new Queue(QUEUE_NAMES.OVERLAP_DETECTION, { connection }) -const maintenanceQueue = new Queue(QUEUE_NAMES.MAINTENANCE, { connection }) - -export async function webhookEventsProcessor(job: Job) { - const { eventType, deliveryId, payload } = job.data - - console.log(`Processing ${eventType} event: ${deliveryId}`) - - try { - switch (eventType) { - case GITHUB_EVENTS.PUSH: - await processPushEvent(payload) - break - - case GITHUB_EVENTS.PULL_REQUEST: - await processPullRequestEvent(payload) - break - - case GITHUB_EVENTS.INSTALLATION: - await processInstallationEvent(payload) - break - - case GITHUB_EVENTS.INSTALLATION_REPOSITORIES: - await processInstallationRepositoriesEvent(payload) - break - - default: - console.log(`Unhandled event type: ${eventType}`) - } - - // Mark event as processed - await db - .update(webhookEvents) - .set({ processedAt: new Date() }) - .where(eq(webhookEvents.deliveryId, deliveryId)) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - - await db - .update(webhookEvents) - .set({ error: errorMessage }) - .where(eq(webhookEvents.deliveryId, deliveryId)) - - throw error - } -} - -async function processPushEvent(payload: Record) { - const parsed = pushEventSchema.parse(payload) - const branchName = extractBranchFromRef(parsed.ref) - - if (!branchName) { - console.log(`Ignoring non-branch ref: ${parsed.ref}`) - return - } - - // Skip branch deletions (handled synchronously in API) - if (isBranchDeletion(parsed.after)) { - return - } - - // Find the repository - const repo = await db.query.repositories.findFirst({ - where: eq(repositories.githubId, parsed.repository.id), - with: { installation: true }, - }) - - if (!repo) { - console.log(`Repository not found: ${parsed.repository.full_name}`) - return - } - - // Check if this is the default branch - const isDefault = branchName === repo.defaultBranch - - // Upsert branch (including lastPusherGithubId) - const existingBranch = await db.query.branches.findFirst({ - where: and( - eq(branches.repositoryId, repo.id), - eq(branches.name, branchName) - ), - }) - - let branchId: string - - if (existingBranch) { - await db - .update(branches) - .set({ - sha: parsed.after, - lastPusherGithubId: parsed.sender.id, - lastSeenAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(branches.id, existingBranch.id)) - branchId = existingBranch.id - } else { - const [newBranch] = await db - .insert(branches) - .values({ - repositoryId: repo.id, - name: branchName, - sha: parsed.after, - isDefault, - lastPusherGithubId: parsed.sender.id, - lastSeenAt: new Date(), - }) - .returning() - branchId = newBranch.id - } - - // Queue branch sync job - const isForceOrNew = parsed.forced || isBranchCreation(parsed.before) - - await branchSyncQueue.add( - 'sync', - { - repositoryId: repo.id, - branchName, - sha: parsed.after, - installationId: repo.installation.installationId, - }, - { - jobId: `${repo.id}-${branchName}-${parsed.after}`, - } - ) - - // Queue overlap detection (skip for default branch) - if (!isDefault) { - await overlapDetectionQueue.add( - 'detect', - { - repositoryId: repo.id, - branchId, - triggeredBy: 'push', - }, - { - jobId: `${repo.id}-${branchId}-${Date.now()}`, - delay: 5000, // Small delay to ensure sync completes first - } - ) - } -} - -async function processPullRequestEvent(payload: Record) { - const parsed = pullRequestEventSchema.parse(payload) - - // Handle closed/merged PRs and open/sync/reopen actions - const relevantActions = [ - PR_ACTIONS.OPENED, - PR_ACTIONS.SYNCHRONIZE, - PR_ACTIONS.REOPENED, - PR_ACTIONS.CLOSED, - ] - if (!relevantActions.includes(parsed.action as typeof PR_ACTIONS.OPENED)) { - return - } - - // Find the repository - const repo = await db.query.repositories.findFirst({ - where: eq(repositories.githubId, parsed.repository.id), - }) - - if (!repo) { - console.log(`Repository not found: ${parsed.repository.full_name}`) - return - } - - // Find the branch - const branch = await db.query.branches.findFirst({ - where: and( - eq(branches.repositoryId, repo.id), - eq(branches.name, parsed.pull_request.head.ref) - ), - }) - - if (!branch) { - console.log(`Branch not found: ${parsed.pull_request.head.ref}`) - return - } - - // Determine PR state - let prState: 'open' | 'closed' | 'merged' = parsed.pull_request.state - if (parsed.action === PR_ACTIONS.CLOSED && parsed.pull_request.merged) { - prState = 'merged' - } - - // Upsert PR record - await db - .insert(pullRequests) - .values({ - repositoryId: repo.id, - branchId: branch.id, - githubPrNumber: parsed.pull_request.number, - title: parsed.pull_request.title, - state: prState, - }) - .onConflictDoUpdate({ - target: [pullRequests.repositoryId, pullRequests.githubPrNumber], - set: { - title: parsed.pull_request.title, - state: prState, - updatedAt: new Date(), - }, - }) - - // Only queue overlap detection for open/reopened/synchronize (not closed) - if (parsed.action !== PR_ACTIONS.CLOSED) { - await overlapDetectionQueue.add( - 'detect', - { - repositoryId: repo.id, - branchId: branch.id, - triggeredBy: 'push', - }, - { - jobId: `${repo.id}-${branch.id}-pr-${parsed.number}`, - } - ) - } -} - -async function processInstallationEvent(payload: Record) { - const parsed = installationEventSchema.parse(payload) - - if (parsed.action === 'created') { - // Create or update installation record - const accountType = parsed.installation.account.type - - let organizationId: string | null = null - - if (accountType === 'Organization') { - // Upsert organization - const [org] = await db - .insert(db._.fullSchema.organizations) - .values({ - githubId: parsed.installation.account.id, - name: parsed.installation.account.login, - avatarUrl: parsed.installation.account.avatar_url, - }) - .onConflictDoUpdate({ - target: db._.fullSchema.organizations.githubId, - set: { - name: parsed.installation.account.login, - avatarUrl: parsed.installation.account.avatar_url, - updatedAt: new Date(), - }, - }) - .returning() - organizationId = org.id - } - - // Link installation to user via sender.id (the person who installed) - let userId: string | null = null - const user = await db.query.users.findFirst({ - where: eq(users.githubId, parsed.sender.id), - }) - if (user) { - userId = user.id - } - - // Create installation record - const [installation] = await db - .insert(githubAppInstallations) - .values({ - installationId: parsed.installation.id, - organizationId, - userId, - status: 'active', - }) - .onConflictDoUpdate({ - target: githubAppInstallations.installationId, - set: { - status: 'active', - updatedAt: new Date(), - }, - }) - .returning() - - // Link user to installation (many-to-many) - if (userId) { - await db - .insert(userInstallations) - .values({ userId, installationId: installation.id }) - .onConflictDoNothing() - } - - console.log(`Installation created: ${parsed.installation.id} (userId: ${userId})`) - - // Sync initial repositories - if (parsed.repositories && parsed.repositories.length > 0) { - const installation = await db.query.githubAppInstallations.findFirst({ - where: eq(githubAppInstallations.installationId, parsed.installation.id), - }) - - if (installation) { - for (const repo of parsed.repositories) { - const [inserted] = await db - .insert(repositories) - .values({ - githubId: repo.id, - installationId: installation.id, - name: repo.name, - fullName: repo.full_name, - isPrivate: repo.private, - isActive: true, - }) - .onConflictDoUpdate({ - target: repositories.githubId, - set: { - isActive: true, - updatedAt: new Date(), - }, - }) - .returning() - - // Queue sync for each repo - await maintenanceQueue.add( - 'sync_repository', - { - type: 'sync_repository' as const, - repositoryId: inserted.id, - }, - { - jobId: `sync_repository-${inserted.id}`, - } - ) - } - } - } - } else if (parsed.action === 'deleted') { - // Find the installation record first - const existing = await db.query.githubAppInstallations.findFirst({ - where: eq(githubAppInstallations.installationId, parsed.installation.id), - }) - - if (existing) { - // Remove user-installation links for this installation - await db - .delete(userInstallations) - .where(eq(userInstallations.installationId, existing.id)) - } - - // Mark installation as deleted - await db - .update(githubAppInstallations) - .set({ status: 'deleted', updatedAt: new Date() }) - .where(eq(githubAppInstallations.installationId, parsed.installation.id)) - - console.log(`Installation deleted: ${parsed.installation.id}`) - } -} - -async function processInstallationRepositoriesEvent(payload: Record) { - // Handle repository additions/removals from installation - const action = payload.action as string - const installationData = payload.installation as { id: number } - - const installation = await db.query.githubAppInstallations.findFirst({ - where: eq(githubAppInstallations.installationId, installationData.id), - }) - - if (!installation) { - console.log(`Installation not found: ${installationData.id}`) - return - } - - if (action === 'added') { - const repos = payload.repositories_added as Array<{ - id: number - name: string - full_name: string - private: boolean - }> - - for (const repo of repos) { - const [inserted] = await db - .insert(repositories) - .values({ - githubId: repo.id, - installationId: installation.id, - name: repo.name, - fullName: repo.full_name, - isPrivate: repo.private, - isActive: true, - }) - .onConflictDoUpdate({ - target: repositories.githubId, - set: { - isActive: true, - updatedAt: new Date(), - }, - }) - .returning() - - // Queue sync for new repo - await maintenanceQueue.add( - 'sync_repository', - { - type: 'sync_repository' as const, - repositoryId: inserted.id, - }, - { - jobId: `sync_repository-${inserted.id}`, - } - ) - } - } else if (action === 'removed') { - const repos = payload.repositories_removed as Array<{ id: number }> - - for (const repo of repos) { - await db - .update(repositories) - .set({ isActive: false, updatedAt: new Date() }) - .where(eq(repositories.githubId, repo.id)) - } - } -} diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json deleted file mode 100644 index 5c47206..0000000 --- a/apps/worker/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "noEmit": false - }, - "include": ["src/**/*"] -} diff --git a/apps/worker/tsup.config.ts b/apps/worker/tsup.config.ts deleted file mode 100644 index 61a1e2a..0000000 --- a/apps/worker/tsup.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: ['src/index.ts'], - format: ['esm'], - clean: true, - noExternal: ['@overlap/shared', '@overlap/db', '@overlap/github'], -}) diff --git a/docker-compose.yml b/docker-compose.yml index a4a546c..3499233 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,19 +16,5 @@ services: timeout: 5s retries: 5 - redis: - image: redis:7-alpine - container_name: overlap-redis - ports: - - "6379:6379" - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 5s - retries: 5 - volumes: postgres_data: - redis_data: diff --git a/docs/superpowers/plans/2026-08-12-vercel-migration.md b/docs/superpowers/plans/2026-08-12-vercel-migration.md new file mode 100644 index 0000000..7b78982 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-vercel-migration.md @@ -0,0 +1,1607 @@ +# Vercel Migration Implementation Plan + +> **SUPERSEDED, 2026-08-13: this plan has already been fully executed, on branch `feat/vercel-migration`.** It is retained below only as a historical record of what was planned, not as instructions to run. Do not execute it, task-by-task or otherwise: the unchecked `- [ ]` boxes below are stale, not pending work, and re-running this plan would revert the completed migration - in particular it would reintroduce Supabase (the actual provider is Neon; see the amendment at the top of `docs/superpowers/specs/2026-08-12-vercel-migration-design.md`) and overwrite the corrected `.env.example` pooler comment with the old, wrong one. If you were handed this document to "continue" or "finish" the migration, stop and check the current state of `feat/vercel-migration` instead. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move Overlap from three Railway services plus Redis onto a single Vercel project backed by Supabase, replacing the always-on BullMQ worker with Workflow DevKit durable workflows. + +**Architecture:** `apps/web` absorbs the Fastify API as TanStack Start server routes and the BullMQ worker as WDK workflows. Redis and BullMQ are deleted outright. Each GitHub webhook delivery becomes one durable workflow run whose steps execute in a guaranteed order, replacing six independent queues that had no ordering between them. + +**Tech Stack:** TanStack Start (React 19, Vite 7), Nitro, Workflow DevKit 4.8.2, Drizzle ORM, `postgres-js`, Supabase Postgres, `jose`, Vitest, Vercel. + +**Spec:** `docs/superpowers/specs/2026-08-12-vercel-migration-design.md` + +## Global Constraints + +- Node `>=20.0.0`, pnpm `9.15.0`, ESM only (`"type": "module"` in every package). +- Workflow DevKit is `workflow@4.8.2`, installed in `apps/web`. Its authoritative docs are at `apps/web/node_modules/workflow/docs/`. Read those, not memory. +- `"use workflow"` functions run sandboxed with no Node.js access. All I/O lives in `"use step"` functions. +- Step default is `maxRetries = 3`, meaning up to 4 total attempts. +- No em dashes in any file, per the repository's authoring conventions. +- HMAC verification of the raw webhook body precedes every database write and every workflow start. This ordering is a security requirement, not a preference (spec S2). +- The session token carries `userId` only. The per-request database lookup is retained (spec S4). +- `packages/github`, `packages/db` schema, and `packages/shared` validation are carried across unchanged unless a task says otherwise. + +--- + +## Correction to the spec, discovered while reading WDK docs + +The spec's idempotency section claims that step memoization prevents a retry from re-posting a check run. That is only true for a step that has already completed. A step that calls GitHub successfully and then fails before returning will retry and call GitHub again. + +`apps/web/node_modules/workflow/docs/foundations/idempotency.mdx` is explicit about this and is the payment-charge example verbatim. + +GitHub's check run API accepts no idempotency key, and creating a second check run with the same name on the same SHA produces a visible duplicate rather than an update. Task 9 addresses this by adding `updateCheckRun` to the GitHub client and having `postCheckRun` update the existing `pr_alerts.checkRunId` when one is present. + +A second finding: `packages/github/src/client.ts:15` defines a `withRetry` helper that already retries on rate limits internally. Under WDK this double-retries, because the step wrapper retries too, and the inner retry burns the step's wall-clock budget. Task 9 removes `withRetry` and lets WDK own retry policy. + +--- + +## Phase 1: Foundations + +### Task 1: Test infrastructure + +The repository currently has no test runner, no test script, and no tests. Every later task in this plan is written test-first, so this must exist first. + +**Files:** +- Create: `apps/web/vitest.config.ts` +- Create: `apps/web/src/lib/__tests__/smoke.test.ts` +- Modify: `apps/web/package.json` +- Modify: `turbo.json` + +**Interfaces:** +- Consumes: nothing +- Produces: `pnpm --filter @overlap/web test` runs Vitest. `pnpm test` runs it through Turborepo. + +- [ ] **Step 1: Install Vitest** + +```bash +pnpm --filter @overlap/web add -D vitest +``` + +- [ ] **Step 2: Write the Vitest config** + +```typescript +// apps/web/vitest.config.ts +import { defineConfig } from 'vitest/config' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +}) +``` + +- [ ] **Step 3: Write a smoke test that fails** + +```typescript +// apps/web/src/lib/__tests__/smoke.test.ts +import { describe, it, expect } from 'vitest' + +describe('test harness', () => { + it('runs', () => { + expect(1 + 1).toBe(3) + }) +}) +``` + +- [ ] **Step 4: Add the test script** + +In `apps/web/package.json`, add to `"scripts"`: + +```json +"test": "vitest run", +"test:watch": "vitest" +``` + +In `turbo.json`, add to `"tasks"`: + +```json +"test": { + "dependsOn": ["^build"], + "outputs": [] +} +``` + +- [ ] **Step 5: Run the test and confirm it fails** + +Run: `pnpm --filter @overlap/web test` +Expected: FAIL, `expected 2 to be 3` + +- [ ] **Step 6: Correct the assertion** + +Change `toBe(3)` to `toBe(2)` in the smoke test. + +- [ ] **Step 7: Run the test and confirm it passes** + +Run: `pnpm --filter @overlap/web test` +Expected: PASS, 1 test. + +- [ ] **Step 8: Commit** + +```bash +git add apps/web/vitest.config.ts apps/web/src/lib/__tests__/smoke.test.ts apps/web/package.json turbo.json pnpm-lock.yaml +git commit -m "test: add Vitest to web app" +``` + +--- + +### Task 2: Vite and Nitro configuration for Vercel and WDK + +**Files:** +- Modify: `apps/web/vite.config.ts` +- Modify: `apps/web/tsconfig.json` + +**Interfaces:** +- Consumes: nothing +- Produces: `"use workflow"` and `"use step"` directives compile. Build output targets Vercel. + +The current config at `apps/web/vite.config.ts:22` uses `preset: 'node_server'` and proxies `/auth` and `/api` to a separate API server in both dev (`server.proxy`) and production (`nitro.routeRules`). Once the API is folded in, all of that must go. + +- [ ] **Step 1: Rewrite the Vite config** + +```typescript +// apps/web/vite.config.ts +import { defineConfig } from 'vite' +import viteReact from '@vitejs/plugin-react' +import tsconfigPaths from 'vite-tsconfig-paths' +import tailwindcss from '@tailwindcss/vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { nitro } from 'nitro/vite' +import { workflow } from 'workflow/vite' + +export default defineConfig({ + server: { + port: 3000, + strictPort: true, + }, + plugins: [ + tailwindcss(), + tsconfigPaths(), + workflow(), + tanstackStart(), + nitro({ + preset: 'vercel', + }), + viteReact(), + ], +}) +``` + +Note the ordering. `workflow()` comes before `tanstackStart()`, as shown in `apps/web/node_modules/workflow/docs/getting-started/tanstack-start.mdx`. + +- [ ] **Step 2: Add the WDK TypeScript plugin** + +In `apps/web/tsconfig.json`, add under `compilerOptions`: + +```json +"plugins": [{ "name": "workflow" }] +``` + +- [ ] **Step 3: Verify the build succeeds** + +Run: `pnpm --filter @overlap/web build` +Expected: build completes, output written to `.vercel/output` or `.output` depending on preset resolution. Any failure here is a configuration problem and must be resolved before continuing. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/vite.config.ts apps/web/tsconfig.json +git commit -m "build: target Vercel preset and enable Workflow DevKit plugin" +``` + +--- + +### Task 3: Database client for Supabase transaction pooling + +**Files:** +- Modify: `packages/db/src/client.ts` +- Modify: `.env.example` + +**Interfaces:** +- Consumes: nothing +- Produces: `db` (unchanged export, now pooler-safe), `migrationClient` (now uses `DIRECT_URL`). + +Supavisor transaction mode cannot support prepared statements, and `postgres-js` enables them by default. Without `prepare: false` the app fails intermittently under concurrency with `prepared statement "s1" already exists`. + +- [ ] **Step 1: Rewrite the client** + +```typescript +// packages/db/src/client.ts +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import * as schema from './schema/index.js' + +const connectionString = process.env.DATABASE_URL + +if (!connectionString) { + throw new Error('DATABASE_URL environment variable is not set') +} + +// Supabase Supavisor transaction pooler (port 6543) cannot use prepared statements. +const queryClient = postgres(connectionString, { prepare: false }) + +// Migrations require session mode, so they use the direct connection (port 5432). +const directConnectionString = process.env.DIRECT_URL || connectionString + +export const migrationClient = postgres(directConnectionString, { + max: 1, + prepare: false, +}) + +export const db = drizzle(queryClient, { schema }) + +export type Database = typeof db +``` + +- [ ] **Step 2: Update `.env.example`** + +Replace the `# Database` and `# Redis` blocks with: + +``` +# Database (Supabase) +# Transaction pooler, port 6543, used by the application +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/overlap +# Direct connection, port 5432, used by migrations only +DIRECT_URL=postgresql://postgres:postgres@localhost:5432/overlap +``` + +Delete the `REDIS_URL` line entirely. + +- [ ] **Step 3: Verify typecheck passes** + +Run: `pnpm --filter @overlap/db typecheck` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/db/src/client.ts .env.example +git commit -m "feat(db): configure client for Supabase transaction pooling" +``` + +--- + +## Phase 2: Session and route fold-in + +### Task 4: Session token utility + +**Files:** +- Create: `apps/web/src/lib/session.ts` +- Create: `apps/web/src/lib/__tests__/session.test.ts` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `signSession(userId: string): Promise` + - `verifySession(token: string): Promise<{ userId: string } | null>` + - `SESSION_COOKIE_NAME: string` (value `"session"`) + - `SESSION_MAX_AGE_SECONDS: number` (value `604800`) + +Spec S4 requires: `userId` only in the token, algorithm pinned explicitly on verify, `exp` of seven days. + +- [ ] **Step 1: Install jose** + +```bash +pnpm --filter @overlap/web add jose +``` + +- [ ] **Step 2: Write the failing tests** + +```typescript +// apps/web/src/lib/__tests__/session.test.ts +import { describe, it, expect, beforeAll } from 'vitest' +import { signSession, verifySession } from '../session' + +beforeAll(() => { + process.env.SESSION_SECRET = 'test-secret-value-at-least-32-bytes-long' +}) + +describe('session', () => { + it('round-trips a userId', async () => { + const token = await signSession('user-123') + const result = await verifySession(token) + expect(result).toEqual({ userId: 'user-123' }) + }) + + it('rejects a tampered token', async () => { + const token = await signSession('user-123') + const tampered = token.slice(0, -4) + 'aaaa' + expect(await verifySession(tampered)).toBeNull() + }) + + it('rejects a token signed with a different secret', async () => { + const token = await signSession('user-123') + process.env.SESSION_SECRET = 'a-completely-different-secret-value-32b' + const result = await verifySession(token) + process.env.SESSION_SECRET = 'test-secret-value-at-least-32-bytes-long' + expect(result).toBeNull() + }) + + it('rejects a malformed token', async () => { + expect(await verifySession('not-a-jwt')).toBeNull() + }) + + it('carries no claims beyond userId, iat and exp', async () => { + const token = await signSession('user-123') + const payload = JSON.parse( + Buffer.from(token.split('.')[1], 'base64url').toString() + ) + expect(Object.keys(payload).sort()).toEqual(['exp', 'iat', 'userId']) + }) +}) +``` + +- [ ] **Step 3: Run the tests to confirm they fail** + +Run: `pnpm --filter @overlap/web test` +Expected: FAIL, cannot resolve `../session`. + +- [ ] **Step 4: Implement the session utility** + +```typescript +// apps/web/src/lib/session.ts +import { SignJWT, jwtVerify } from 'jose' + +export const SESSION_COOKIE_NAME = 'session' +export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7 + +const ALGORITHM = 'HS256' + +function getKey(): Uint8Array { + const secret = process.env.SESSION_SECRET + if (!secret) { + throw new Error('SESSION_SECRET environment variable is required') + } + return new TextEncoder().encode(secret) +} + +export async function signSession(userId: string): Promise { + return new SignJWT({ userId }) + .setProtectedHeader({ alg: ALGORITHM }) + .setIssuedAt() + .setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`) + .sign(getKey()) +} + +export async function verifySession( + token: string +): Promise<{ userId: string } | null> { + try { + const { payload } = await jwtVerify(token, getKey(), { + algorithms: [ALGORITHM], + }) + const userId = payload.userId + if (typeof userId !== 'string') return null + return { userId } + } catch { + return null + } +} +``` + +The `algorithms: [ALGORITHM]` option is required. Without it, `jose` would accept whatever algorithm the token header claims, which is the algorithm confusion class of vulnerability. + +- [ ] **Step 5: Run the tests to confirm they pass** + +Run: `pnpm --filter @overlap/web test` +Expected: PASS, 5 session tests plus the smoke test. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/lib/session.ts apps/web/src/lib/__tests__/session.test.ts apps/web/package.json pnpm-lock.yaml +git commit -m "feat(web): add jose-backed session token utility" +``` + +--- + +### Task 5: Auth helper for server routes + +**Files:** +- Create: `apps/web/src/lib/auth.ts` +- Create: `apps/web/src/lib/__tests__/auth.test.ts` + +**Interfaces:** +- Consumes: `signSession`, `verifySession`, `SESSION_COOKIE_NAME` from Task 4 +- Produces: + - `getUser(request: Request): Promise` + - `requireUser(request: Request): Promise` which throws a `Response` with status 401 + - `type AuthUser = { id: string; githubId: number; username: string; email: string | null; avatarUrl: string | null }` + - `readCookie(request: Request, name: string): string | null` + - `buildSessionCookie(token: string): string` + - `buildClearCookie(name: string): string` + +This is the direct port of `apps/api/src/plugins/auth.ts`. The database lookup on every request is retained deliberately, per spec S4. + +- [ ] **Step 1: Write the failing tests** + +```typescript +// apps/web/src/lib/__tests__/auth.test.ts +import { describe, it, expect } from 'vitest' +import { readCookie, buildSessionCookie, buildClearCookie } from '../auth' + +function reqWithCookie(value: string): Request { + return new Request('https://example.com/', { headers: { cookie: value } }) +} + +describe('readCookie', () => { + it('reads a single cookie', () => { + expect(readCookie(reqWithCookie('session=abc'), 'session')).toBe('abc') + }) + + it('reads one cookie among several', () => { + const r = reqWithCookie('a=1; session=abc; b=2') + expect(readCookie(r, 'session')).toBe('abc') + }) + + it('returns null when absent', () => { + expect(readCookie(reqWithCookie('a=1'), 'session')).toBeNull() + }) + + it('returns null when there is no cookie header', () => { + expect(readCookie(new Request('https://example.com/'), 'session')).toBeNull() + }) + + it('does not match a cookie whose name is a suffix', () => { + expect(readCookie(reqWithCookie('oauth_session=abc'), 'session')).toBeNull() + }) +}) + +describe('buildSessionCookie', () => { + it('sets HttpOnly, SameSite=Lax and Path', () => { + const c = buildSessionCookie('tok') + expect(c).toContain('session=tok') + expect(c).toContain('HttpOnly') + expect(c).toContain('SameSite=Lax') + expect(c).toContain('Path=/') + expect(c).toContain('Max-Age=604800') + }) +}) + +describe('buildClearCookie', () => { + it('expires the cookie immediately', () => { + expect(buildClearCookie('session')).toContain('Max-Age=0') + }) +}) +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `pnpm --filter @overlap/web test` +Expected: FAIL, cannot resolve `../auth`. + +- [ ] **Step 3: Implement the auth helper** + +```typescript +// apps/web/src/lib/auth.ts +import { db, users } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { + verifySession, + SESSION_COOKIE_NAME, + SESSION_MAX_AGE_SECONDS, +} from './session' + +export type AuthUser = { + id: string + githubId: number + username: string + email: string | null + avatarUrl: string | null +} + +export function readCookie(request: Request, name: string): string | null { + const header = request.headers.get('cookie') + if (!header) return null + for (const part of header.split(';')) { + const eq = part.indexOf('=') + if (eq === -1) continue + if (part.slice(0, eq).trim() === name) { + return part.slice(eq + 1).trim() + } + } + return null +} + +function isProduction(): boolean { + return process.env.NODE_ENV === 'production' +} + +export function buildSessionCookie(token: string): string { + const parts = [ + `${SESSION_COOKIE_NAME}=${token}`, + 'HttpOnly', + 'SameSite=Lax', + 'Path=/', + `Max-Age=${SESSION_MAX_AGE_SECONDS}`, + ] + if (isProduction()) parts.push('Secure') + return parts.join('; ') +} + +export function buildClearCookie(name: string): string { + const parts = [`${name}=`, 'HttpOnly', 'SameSite=Lax', 'Path=/', 'Max-Age=0'] + if (isProduction()) parts.push('Secure') + return parts.join('; ') +} + +export async function getUser(request: Request): Promise { + const token = readCookie(request, SESSION_COOKIE_NAME) + if (!token) return null + + const session = await verifySession(token) + if (!session) return null + + // Retained deliberately: this lookup is what makes revocation immediate. + const user = await db.query.users.findFirst({ + where: eq(users.id, session.userId), + }) + if (!user) return null + + return { + id: user.id, + githubId: user.githubId, + username: user.username, + email: user.email, + avatarUrl: user.avatarUrl, + } +} + +export async function requireUser(request: Request): Promise { + const user = await getUser(request) + if (!user) { + throw new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }) + } + return user +} +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `pnpm --filter @overlap/web test` +Expected: PASS, all auth and session tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/auth.ts apps/web/src/lib/__tests__/auth.test.ts +git commit -m "feat(web): add request auth helper with retained user lookup" +``` + +--- + +### Task 6: Health and auth server routes + +**Files:** +- Create: `apps/web/src/routes/api/health.ts` +- Create: `apps/web/src/routes/api/auth/github.ts` +- Create: `apps/web/src/routes/api/auth/github.callback.ts` +- Create: `apps/web/src/routes/api/auth/me.ts` +- Create: `apps/web/src/routes/api/auth/logout.ts` +- Create: `apps/web/src/lib/github-oauth.ts` +- Reference: `apps/api/src/routes/auth.ts`, `apps/api/src/routes/health.ts` + +**Interfaces:** +- Consumes: `getUser`, `requireUser`, `buildSessionCookie`, `buildClearCookie`, `readCookie` from Task 5, `signSession` from Task 4 +- Produces: `syncUserInstallations(accessToken: string, userId: string): Promise` exported from `apps/web/src/lib/github-oauth.ts` + +Per spec S9, the health route drops its Redis check. Per spec S7, the `oauth_state` cookie keeps integrity protection, which here means it is itself a signed JWT rather than a bare random value. + +- [ ] **Step 1: Write the health route** + +```typescript +// apps/web/src/routes/api/health.ts +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db } from '@overlap/db' +import { sql } from 'drizzle-orm' + +export const Route = createFileRoute('/api/health')({ + server: { + handlers: { + GET: async () => { + let database = false + try { + await db.execute(sql`SELECT 1`) + database = true + } catch { + database = false + } + + return json( + { + status: database ? 'ready' : 'not ready', + checks: { database }, + timestamp: new Date().toISOString(), + }, + { status: database ? 200 : 503 } + ) + }, + }, + }, +}) +``` + +- [ ] **Step 2: Port `syncUserInstallations` verbatim** + +Copy the `syncUserInstallations` function from `apps/api/src/routes/auth.ts` into `apps/web/src/lib/github-oauth.ts`, changing only its imports to point at `@overlap/db`. Export it. Do not alter its logic in this task. + +- [ ] **Step 3: Write the OAuth start route** + +```typescript +// apps/web/src/routes/api/auth/github.ts +import { createFileRoute } from '@tanstack/react-router' +import { SignJWT } from 'jose' + +export const Route = createFileRoute('/api/auth/github')({ + server: { + handlers: { + GET: async () => { + const clientId = process.env.GITHUB_CLIENT_ID + const appUrl = process.env.APP_URL || 'http://localhost:3000' + if (!clientId) { + return new Response('OAuth not configured', { status: 500 }) + } + + const state = crypto.randomUUID() + + // The state cookie is signed, not merely present, per spec S7. + const stateToken = await new SignJWT({ state }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('10m') + .sign(new TextEncoder().encode(process.env.SESSION_SECRET!)) + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: `${appUrl}/api/auth/github/callback`, + scope: 'read:user user:email', + state, + }) + + const cookieParts = [ + `oauth_state=${stateToken}`, + 'HttpOnly', + 'SameSite=Lax', + 'Path=/', + 'Max-Age=600', + ] + if (process.env.NODE_ENV === 'production') cookieParts.push('Secure') + + return new Response(null, { + status: 302, + headers: { + location: `https://github.com/login/oauth/authorize?${params}`, + 'set-cookie': cookieParts.join('; '), + }, + }) + }, + }, + }, +}) +``` + +- [ ] **Step 4: Write the OAuth callback route** + +Port `apps/api/src/routes/auth.ts:41-155` into `apps/web/src/routes/api/auth/github.callback.ts`, with these changes and no others: + +- Read `oauth_state` with `readCookie`, verify it with `jwtVerify` pinned to `HS256`, and compare its `state` claim to the `state` query parameter. A missing or invalid cookie redirects to `${appUrl}/api/auth/github` exactly as the current code does at line 50. +- Replace `reply.setCookie('session', JSON.stringify({ userId: user.id }), ...)` with `buildSessionCookie(await signSession(user.id))`. +- Clear `oauth_state` with `buildClearCookie('oauth_state')`. +- Return `Response` objects with `location` and `set-cookie` headers rather than calling `reply.redirect`. +- Keep the `syncUserInstallations` call and the `hasActive` redirect branch exactly as they are. + +- [ ] **Step 5: Write the `me` and `logout` routes** + +```typescript +// apps/web/src/routes/api/auth/me.ts +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { db, userInstallations } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { requireUser } from '../../../lib/auth' + +export const Route = createFileRoute('/api/auth/me')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const user = await requireUser(request) + const insts = await db.query.userInstallations.findMany({ + where: eq(userInstallations.userId, user.id), + with: { installation: true }, + }) + return json({ + user, + hasInstallations: insts.some( + (ui) => ui.installation.status === 'active' + ), + }) + } catch (res) { + if (res instanceof Response) return res + throw res + } + }, + }, + }, +}) +``` + +```typescript +// apps/web/src/routes/api/auth/logout.ts +import { createFileRoute } from '@tanstack/react-router' +import { buildClearCookie } from '../../../lib/auth' +import { SESSION_COOKIE_NAME } from '../../../lib/session' + +export const Route = createFileRoute('/api/auth/logout')({ + server: { + handlers: { + POST: async () => { + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { + 'content-type': 'application/json', + 'set-cookie': buildClearCookie(SESSION_COOKIE_NAME), + }, + }) + }, + }, + }, +}) +``` + +- [ ] **Step 6: Verify typecheck and build** + +Run: `pnpm --filter @overlap/web typecheck && pnpm --filter @overlap/web build` +Expected: both succeed. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/routes/api apps/web/src/lib/github-oauth.ts +git commit -m "feat(web): port health and auth routes to server routes" +``` + +--- + +### Task 7: Repositories and push server routes + +**Files:** +- Create: `apps/web/src/routes/api/repositories.ts` +- Create: `apps/web/src/routes/api/repositories.$id.ts` +- Create: `apps/web/src/routes/api/push.ts` +- Create: `apps/web/src/lib/repo-access.ts` +- Reference: `apps/api/src/routes/repositories.ts`, `apps/api/src/routes/push.ts` + +**Interfaces:** +- Consumes: `requireUser` from Task 5 +- Produces: `requireRepoAccess(user: AuthUser, repoId: string): Promise` which throws a `Response` with status 403 or 404 + +The spec's "verified clean" note records that `requireRepoAccess` is the control preventing insecure direct object references. It is carried across with its logic unchanged. Every `:id` route must call it before reading or writing, matching `apps/api/src/routes/repositories.ts:101` and every other handler in that file. + +- [ ] **Step 1: Port `requireRepoAccess`** + +Copy the `requireRepoAccess` helper from `apps/api/src/routes/repositories.ts` into `apps/web/src/lib/repo-access.ts`. Change its signature from `(request, reply, id)` to `(user: AuthUser, repoId: string)`, and change its failure paths from `reply.status(...).send(...)` to `throw new Response(...)`. The access-checking logic itself is unchanged. + +- [ ] **Step 2: Port every repositories handler** + +Port all nine handlers from `apps/api/src/routes/repositories.ts` into the two route files, splitting by path shape. Each handler: + +- calls `await requireUser(request)` first +- calls `await requireRepoAccess(user, id)` before any data access, for every route with an `:id` parameter +- keeps its existing Zod parsing (`repositoryIdParamSchema`, `repositorySettingsUpdateSchema`, and the querystring schemas) exactly as-is +- returns `json(...)` instead of returning a bare object + +- [ ] **Step 3: Port the push routes** + +Port `apps/api/src/routes/push.ts` in full. Keep `ALLOWED_PUSH_HOSTS` and `isAllowedPushEndpoint` byte-for-byte; that allowlist is the control preventing the push endpoint from being pointed at arbitrary hosts. + +Add the per-user subscription cap required by spec S3, before the insert: + +```typescript +const MAX_SUBSCRIPTIONS_PER_USER = 20 + +const existing = await db.query.pushSubscriptions.findMany({ + where: eq(pushSubscriptions.userId, user.id), +}) + +const isKnownEndpoint = existing.some((s) => s.endpoint === endpoint) + +if (!isKnownEndpoint && existing.length >= MAX_SUBSCRIPTIONS_PER_USER) { + return json({ error: 'Subscription limit reached' }, { status: 429 }) +} +``` + +- [ ] **Step 4: Verify typecheck and build** + +Run: `pnpm --filter @overlap/web typecheck && pnpm --filter @overlap/web build` +Expected: both succeed. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/api apps/web/src/lib/repo-access.ts +git commit -m "feat(web): port repositories and push routes to server routes" +``` + +--- + +## Phase 3: Workflows + +### Task 8: Workflow steps + +**Files:** +- Create: `apps/web/src/workflows/steps.ts` +- Create: `apps/web/src/workflows/errors.ts` +- Create: `apps/web/src/workflows/__tests__/errors.test.ts` +- Reference: all six files in `apps/worker/src/processors/` + +**Interfaces:** +- Consumes: `@overlap/db`, `@overlap/github`, `@overlap/shared` +- Produces, all as `"use step"` functions: + - `loadEvent(deliveryId: string): Promise` + - `upsertBranch(deliveryId: string): Promise<{ branchId: string; repositoryId: string; installationId: number; branchName: string; sha: string; isDefault: boolean } | null>` + - `syncBranchFiles(input: { repositoryId: string; branchName: string; sha: string; installationId: number }): Promise<{ filesIndexed: number }>` + - `detectOverlaps(input: { repositoryId: string; branchId: string }): Promise<{ overlapsFound: number; notifications: NotificationTarget[] }>` + - `postCheckRun(input: { repositoryId: string; pullRequestId: string; overlapId: string }): Promise<{ checkRunId: number | null }>` + - `sendPush(input: { repositoryId: string; overlapId: string; targetBranchId: string }): Promise<{ sent: number }>` + - `upsertPullRequest(deliveryId: string): Promise<{ repositoryId: string; branchId: string } | null>` + - `syncInstallation(deliveryId: string): Promise<{ repositoryIds: string[] }>` + - `syncRepository(repositoryId: string): Promise<{ added: number; updated: number; markedForDeletion: number }>` + - `pruneStaleBranches(repositoryId?: string): Promise<{ prunedBranches: number }>` + - `cleanupOldEvents(): Promise<{ cleaned: boolean }>` + - `type NotificationTarget = { repositoryId: string; overlapId: string; targetBranchId: string; pullRequestIds: string[] }` + +Each function is the body of the corresponding processor with three changes: the `Job` parameter becomes plain arguments, all `queue.add(...)` calls are deleted because the workflow now sequences the work, and error handling follows the mapping below. + +- [ ] **Step 1: Write the failing tests for the error mapping** + +Steps without the compiler treat `"use step"` as a no-op, so they can be tested as plain functions. Test the error classifier in isolation. + +```typescript +// apps/web/src/workflows/__tests__/errors.test.ts +import { describe, it, expect } from 'vitest' +import { FatalError, RetryableError } from 'workflow' +import { classifyGitHubError } from '../errors' + +describe('classifyGitHubError', () => { + it('maps 429 to RetryableError honoring Retry-After', () => { + const err = classifyGitHubError({ + status: 429, + message: 'rate limited', + response: { headers: { 'retry-after': '120' } }, + }) + expect(err).toBeInstanceOf(RetryableError) + expect((err as RetryableError).retryAfter).toBe('120s') + }) + + it('maps 403 to RetryableError', () => { + const err = classifyGitHubError({ status: 403, message: 'forbidden' }) + expect(err).toBeInstanceOf(RetryableError) + }) + + it('defaults Retry-After to 5m when the header is absent', () => { + const err = classifyGitHubError({ status: 429, message: 'rate limited' }) + expect((err as RetryableError).retryAfter).toBe('5m') + }) + + it('maps 500 to RetryableError', () => { + expect(classifyGitHubError({ status: 500, message: 'boom' })).toBeInstanceOf( + RetryableError + ) + }) + + it('maps 404 to FatalError', () => { + expect(classifyGitHubError({ status: 404, message: 'gone' })).toBeInstanceOf( + FatalError + ) + }) + + it('maps 422 to FatalError', () => { + expect( + classifyGitHubError({ status: 422, message: 'unprocessable' }) + ).toBeInstanceOf(FatalError) + }) +}) +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `pnpm --filter @overlap/web test` +Expected: FAIL, cannot resolve `../errors`. + +- [ ] **Step 3: Implement the classifier** + +```typescript +// apps/web/src/workflows/errors.ts +import { FatalError, RetryableError } from 'workflow' + +type GitHubErrorLike = { + status?: number + message?: string + response?: { headers?: Record } +} + +export function classifyGitHubError(err: GitHubErrorLike): Error { + const status = err.status + const message = err.message ?? 'GitHub request failed' + + if (status === 429 || status === 403) { + const header = err.response?.headers?.['retry-after'] + const retryAfter = header ? `${header}s` : '5m' + return new RetryableError(`GitHub rate limited: ${message}`, { retryAfter }) + } + + if (status !== undefined && status >= 400 && status < 500) { + return new FatalError(message) + } + + // 5xx, network failures and unknown shapes are transient. + return new RetryableError(message, { retryAfter: '30s' }) +} +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `pnpm --filter @overlap/web test` +Expected: PASS. + +- [ ] **Step 5: Port the processors into steps** + +Port each processor. Two specific corrections are mandatory and are the reason this task exists rather than a straight copy: + +**`syncBranchFiles`.** `apps/worker/src/processors/branch-sync.ts:52-56` currently catches the GitHub failure and assigns `changedFiles = []`, after which the code unconditionally deletes every `branchFiles` row for the branch. That wipes the file index on any transient failure, and the next detection run then marks genuine overlaps `resolved`. Replace the catch with: + +```typescript +try { + changedFiles = await github.getBranchFiles( + installationId, owner, repoName, branchName, repo.defaultBranch + ) +} catch (error) { + throw classifyGitHubError(error as never) +} +``` + +The delete must not be reachable when the fetch failed. + +**`postCheckRun`.** `apps/worker/src/processors/github-feedback.ts:110-113` swallows `createCheckRun` failures. Replace with `throw classifyGitHubError(error as never)`. The check-run duplication issue is handled separately in Task 9. + +Delete every `Queue`, `Redis` and `queue.add(...)` reference. `detectOverlaps` returns its notification targets to the caller instead of enqueuing them, which is what the `NotificationTarget[]` return type is for. + +- [ ] **Step 6: Verify typecheck** + +Run: `pnpm --filter @overlap/web typecheck` +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/workflows +git commit -m "feat(web): port worker processors to workflow steps + +Fixes two swallowed-error paths carried over from the BullMQ processors: +branch-sync no longer wipes a branch file index on transient GitHub +failure, and check-run creation failures no longer pass silently." +``` + +--- + +### Task 9: Check run idempotency + +**Files:** +- Modify: `packages/github/src/client.ts` +- Modify: `apps/web/src/workflows/steps.ts` + +**Interfaces:** +- Consumes: `postCheckRun` from Task 8 +- Produces: `updateCheckRun(installationId: number, owner: string, repo: string, checkRunId: number, conclusion: string, title: string, summary: string): Promise` on the GitHub client + +A step that calls `createCheckRun` successfully and then fails before returning will retry and create a second check run, because GitHub's API accepts no idempotency key and a repeat call creates a new record rather than updating. `apps/web/node_modules/workflow/docs/foundations/idempotency.mdx` describes this failure mode directly. + +- [ ] **Step 1: Add `updateCheckRun` to the client** + +Add a method alongside `createCheckRun` at `packages/github/src/client.ts:345` that calls `octokit.rest.checks.update` with the same argument shape, taking `check_run_id` instead of `head_sha`. + +- [ ] **Step 2: Remove the internal retry wrapper** + +Delete the `withRetry` helper at `packages/github/src/client.ts:15` and unwrap every call site. WDK owns retry policy now; an inner retry loop burns the step's wall-clock budget and hides the rate-limit signal that `classifyGitHubError` needs to see. + +- [ ] **Step 3: Make `postCheckRun` update rather than duplicate** + +In `postCheckRun`, look up the existing `pr_alerts` row first. If it has a `checkRunId`, call `updateCheckRun`. Only call `createCheckRun` when no check run has been recorded, and write the resulting id to `pr_alerts` immediately. + +- [ ] **Step 4: Verify typecheck** + +Run: `pnpm typecheck` +Expected: no errors across the workspace. + +- [ ] **Step 5: Commit** + +```bash +git add packages/github/src/client.ts apps/web/src/workflows/steps.ts +git commit -m "fix(github): make check run posting idempotent under step retry" +``` + +--- + +### Task 10: The workflow + +**Files:** +- Create: `apps/web/src/workflows/process-webhook.ts` +- Create: `apps/web/src/workflows/maintenance.ts` + +**Interfaces:** +- Consumes: every step from Task 8 +- Produces: + - `processWebhook(deliveryId: string): Promise<{ handled: boolean }>` + - `pruneBranchesWorkflow(): Promise<{ prunedBranches: number }>` + - `cleanupEventsWorkflow(): Promise<{ cleaned: boolean }>` + - `syncRepositoryWorkflow(repositoryId: string): Promise` + +- [ ] **Step 1: Write the workflow** + +```typescript +// apps/web/src/workflows/process-webhook.ts +import { + loadEvent, + upsertBranch, + upsertPullRequest, + syncInstallation, + syncBranchFiles, + detectOverlaps, + postCheckRun, + sendPush, + syncRepository, +} from './steps' + +export async function processWebhook(deliveryId: string) { + 'use workflow' + + const evt = await loadEvent(deliveryId) + + if (evt.type === 'push') { + const branch = await upsertBranch(deliveryId) + if (!branch) return { handled: false } + + await syncBranchFiles({ + repositoryId: branch.repositoryId, + branchName: branch.branchName, + sha: branch.sha, + installationId: branch.installationId, + }) + + if (branch.isDefault) return { handled: true } + + const result = await detectOverlaps({ + repositoryId: branch.repositoryId, + branchId: branch.branchId, + }) + + for (const n of result.notifications) { + for (const pullRequestId of n.pullRequestIds) { + await postCheckRun({ + repositoryId: n.repositoryId, + pullRequestId, + overlapId: n.overlapId, + }) + } + await sendPush({ + repositoryId: n.repositoryId, + overlapId: n.overlapId, + targetBranchId: n.targetBranchId, + }) + } + + return { handled: true } + } + + if (evt.type === 'pull_request') { + const pr = await upsertPullRequest(deliveryId) + if (!pr) return { handled: false } + + const result = await detectOverlaps({ + repositoryId: pr.repositoryId, + branchId: pr.branchId, + }) + + for (const n of result.notifications) { + for (const pullRequestId of n.pullRequestIds) { + await postCheckRun({ + repositoryId: n.repositoryId, + pullRequestId, + overlapId: n.overlapId, + }) + } + await sendPush({ + repositoryId: n.repositoryId, + overlapId: n.overlapId, + targetBranchId: n.targetBranchId, + }) + } + + return { handled: true } + } + + if (evt.type === 'installation') { + const { repositoryIds } = await syncInstallation(deliveryId) + for (const repositoryId of repositoryIds) { + await syncRepository(repositoryId) + } + return { handled: true } + } + + return { handled: false } +} +``` + +The `await syncBranchFiles(...)` followed by `await detectOverlaps(...)` is the entire point of this migration. It replaces the `delay: 5000` at `apps/worker/src/processors/webhook-events.ts:150`, which was a hope that one queue would finish before another started. Do not reintroduce a delay anywhere in this file. + +- [ ] **Step 2: Write the maintenance workflows** + +```typescript +// apps/web/src/workflows/maintenance.ts +import { pruneStaleBranches, cleanupOldEvents, syncRepository } from './steps' + +export async function pruneBranchesWorkflow() { + 'use workflow' + return await pruneStaleBranches() +} + +export async function cleanupEventsWorkflow() { + 'use workflow' + return await cleanupOldEvents() +} + +export async function syncRepositoryWorkflow(repositoryId: string) { + 'use workflow' + await syncRepository(repositoryId) +} +``` + +- [ ] **Step 3: Verify the build compiles the directives** + +Run: `pnpm --filter @overlap/web build` +Expected: build succeeds. A failure mentioning directives means the `workflow()` plugin ordering in Task 2 is wrong. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/workflows +git commit -m "feat(web): add durable webhook and maintenance workflows + +Replaces the 5s delay between branch sync and overlap detection with a +real happens-before edge." +``` + +--- + +### Task 11: Webhook route + +**Files:** +- Create: `apps/web/src/routes/api/webhooks/github.ts` +- Create: `apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts` +- Reference: `apps/api/src/routes/webhooks.ts` + +**Interfaces:** +- Consumes: `processWebhook` from Task 10, `verifyWebhookSignature` from `@overlap/github` +- Produces: `POST /api/webhooks/github` + +Spec S2 makes the ordering here a security requirement. Signature verification of the raw body precedes every database write and every workflow start. + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/web/src/routes/api/webhooks/__tests__/verify-order.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { handleWebhook } from '../github-handler' + +const inserts = vi.fn() +const starts = vi.fn() + +vi.mock('@overlap/db', () => ({ + db: { insert: () => ({ values: inserts }) }, +})) + +beforeEach(() => { + inserts.mockReset() + starts.mockReset() + process.env.GITHUB_WEBHOOK_SECRET = 'test-secret' +}) + +describe('handleWebhook', () => { + it('rejects an invalid signature with 401', async () => { + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': 'sha256=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }) + + const res = await handleWebhook(req, { start: starts }) + expect(res.status).toBe(401) + }) + + it('writes nothing to the database when the signature is invalid', async () => { + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': 'sha256=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }) + + await handleWebhook(req, { start: starts }) + expect(inserts).not.toHaveBeenCalled() + }) + + it('starts no workflow when the signature is invalid', async () => { + const req = new Request('https://example.com/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-event': 'push', + 'x-github-delivery': 'abc-123', + 'x-hub-signature-256': 'sha256=deadbeef', + 'content-type': 'application/json', + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }) + + await handleWebhook(req, { start: starts }) + expect(starts).not.toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `pnpm --filter @overlap/web test` +Expected: FAIL, cannot resolve `../github-handler`. + +- [ ] **Step 3: Implement the handler** + +Extract the logic into `apps/web/src/routes/api/webhooks/github-handler.ts` so it is testable without the route wrapper. It takes the `start` function as an injected dependency, which is what makes the test above possible. + +```typescript +// apps/web/src/routes/api/webhooks/github-handler.ts +import { verifyWebhookSignature } from '@overlap/github' +import { db, webhookEvents, repositories } from '@overlap/db' +import { eq } from 'drizzle-orm' +import { processWebhook } from '../../../workflows/process-webhook' + +type Deps = { start: (wf: unknown, args: unknown[]) => Promise } + +export async function handleWebhook( + request: Request, + deps: Deps +): Promise { + const secret = process.env.GITHUB_WEBHOOK_SECRET + if (!secret) { + return new Response(JSON.stringify({ error: 'Server configuration error' }), { + status: 500, + }) + } + + // Raw bytes, before any parsing. This is what GitHub signed. + const raw = await request.text() + const signature = request.headers.get('x-hub-signature-256') ?? '' + const eventType = request.headers.get('x-github-event') ?? '' + const deliveryId = request.headers.get('x-github-delivery') ?? '' + + // Nothing below this line may execute for an unverified request. + const verification = verifyWebhookSignature(raw, signature, secret) + if (!verification.valid) { + return new Response(JSON.stringify({ error: 'Invalid signature' }), { + status: 401, + }) + } + + let payload: Record + try { + payload = JSON.parse(raw) + } catch { + return new Response(JSON.stringify({ error: 'Invalid JSON payload' }), { + status: 400, + }) + } + + let repositoryId: string | null = null + const repoData = payload.repository as { id?: number } | undefined + if (repoData?.id) { + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.githubId, repoData.id), + }) + repositoryId = repo?.id ?? null + } + + const [row] = await db + .insert(webhookEvents) + .values({ eventType, deliveryId, repositoryId, payload }) + .onConflictDoNothing() + .returning() + + // No row means GitHub redelivered a delivery we already accepted. + if (!row) { + return new Response(JSON.stringify({ received: true }), { status: 200 }) + } + + await deps.start(processWebhook, [deliveryId]) + + return new Response(JSON.stringify({ received: true }), { status: 200 }) +} +``` + +- [ ] **Step 4: Write the thin route wrapper** + +```typescript +// apps/web/src/routes/api/webhooks/github.ts +import { createFileRoute } from '@tanstack/react-router' +import { start } from 'workflow/api' +import { handleWebhook } from './github-handler' + +export const Route = createFileRoute('/api/webhooks/github')({ + server: { + handlers: { + POST: async ({ request }) => handleWebhook(request, { start }), + }, + }, +}) +``` + +- [ ] **Step 5: Run the tests to confirm they pass** + +Run: `pnpm --filter @overlap/web test` +Expected: PASS, all three ordering tests. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/routes/api/webhooks +git commit -m "feat(web): port GitHub webhook route with verify-before-write ordering + +Deduplication moves from BullMQ jobId to the webhook_events.deliveryId +unique constraint, which is durable rather than TTL-bound." +``` + +--- + +### Task 12: Cron routes + +**Files:** +- Create: `apps/web/src/routes/api/cron/prune-branches.ts` +- Create: `apps/web/src/routes/api/cron/cleanup-events.ts` +- Create: `apps/web/src/lib/cron-auth.ts` +- Create: `apps/web/src/lib/__tests__/cron-auth.test.ts` +- Create: `vercel.json` + +**Interfaces:** +- Consumes: `pruneBranchesWorkflow`, `cleanupEventsWorkflow` from Task 10 +- Produces: `isAuthorizedCron(request: Request): boolean` + +Spec S6 requires a timing-safe comparison. These endpoints are publicly routable and start workflows that iterate every active repository. + +- [ ] **Step 1: Write the failing tests** + +```typescript +// apps/web/src/lib/__tests__/cron-auth.test.ts +import { describe, it, expect, beforeEach } from 'vitest' +import { isAuthorizedCron } from '../cron-auth' + +beforeEach(() => { + process.env.CRON_SECRET = 'correct-secret' +}) + +function req(auth?: string): Request { + return new Request('https://example.com/api/cron/prune-branches', { + headers: auth ? { authorization: auth } : {}, + }) +} + +describe('isAuthorizedCron', () => { + it('accepts the correct bearer token', () => { + expect(isAuthorizedCron(req('Bearer correct-secret'))).toBe(true) + }) + + it('rejects a wrong token of the same length', () => { + expect(isAuthorizedCron(req('Bearer wrongxxsecret!'))).toBe(false) + }) + + it('rejects a wrong token of a different length', () => { + expect(isAuthorizedCron(req('Bearer short'))).toBe(false) + }) + + it('rejects a missing header', () => { + expect(isAuthorizedCron(req())).toBe(false) + }) + + it('rejects when CRON_SECRET is unset', () => { + delete process.env.CRON_SECRET + expect(isAuthorizedCron(req('Bearer anything'))).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `pnpm --filter @overlap/web test` +Expected: FAIL, cannot resolve `../cron-auth`. + +- [ ] **Step 3: Implement the check** + +```typescript +// apps/web/src/lib/cron-auth.ts +import { timingSafeEqual } from 'node:crypto' + +export function isAuthorizedCron(request: Request): boolean { + const secret = process.env.CRON_SECRET + if (!secret) return false + + const header = request.headers.get('authorization') + if (!header?.startsWith('Bearer ')) return false + + const provided = Buffer.from(header.slice('Bearer '.length)) + const expected = Buffer.from(secret) + + // timingSafeEqual throws on length mismatch, so compare lengths first. + // The length itself is not secret; the contents are. + if (provided.length !== expected.length) return false + + return timingSafeEqual(provided, expected) +} +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `pnpm --filter @overlap/web test` +Expected: PASS, 5 cron auth tests. + +- [ ] **Step 5: Write the two cron routes** + +Each route checks `isAuthorizedCron(request)`, returns 401 when it fails, and otherwise calls `start(...)` on its workflow and returns 200 immediately. + +- [ ] **Step 6: Write `vercel.json`** + +```json +{ + "crons": [ + { "path": "/api/cron/prune-branches", "schedule": "0 */6 * * *" }, + { "path": "/api/cron/cleanup-events", "schedule": "0 3 * * *" } + ] +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/routes/api/cron apps/web/src/lib/cron-auth.ts apps/web/src/lib/__tests__/cron-auth.test.ts vercel.json +git commit -m "feat: replace BullMQ job scheduler with Vercel Cron" +``` + +--- + +## Phase 4: Teardown + +### Task 13: Delete the old apps and dependencies + +**Files:** +- Delete: `apps/api/`, `apps/worker/` +- Delete: `railway.json`, `apps/web/Dockerfile` +- Modify: `docker-compose.yml`, `packages/shared/src/constants/index.ts` + +**Interfaces:** +- Consumes: everything above +- Produces: a workspace with one deployable app + +Do this only after Tasks 1 through 12 are green. Until then the old code is the reference for the port. + +No frontend changes are required. `VITE_API_URL` appears nowhere in `apps/web/src`; it existed only as a dev proxy target in `vite.config.ts`, which Task 2 already removed. The React code already fetches relative paths such as `/api/repositories`, which now resolve to the folded-in server routes on the same origin. + +- [ ] **Step 1: Confirm nothing still imports the deleted packages** + +Run: `grep -rn "bullmq\|ioredis\|REDIS_URL\|VITE_API_URL\|API_URL" apps/web/src packages/ --include=*.ts --include=*.tsx` +Expected: no results. Fix any that appear before continuing. + +- [ ] **Step 2: Delete the directories and files** + +```bash +git rm -r apps/api apps/worker +git rm railway.json apps/web/Dockerfile +``` + +- [ ] **Step 3: Remove `QUEUE_NAMES` and `RATE_LIMITS`** + +Delete both exports from `packages/shared/src/constants/index.ts:11-27`. Keep `DEFAULT_SETTINGS` and everything below it. + +- [ ] **Step 4: Remove the Redis service from `docker-compose.yml`** + +- [ ] **Step 5: Verify the whole workspace builds and tests clean** + +Run: `pnpm install && pnpm typecheck && pnpm lint && pnpm test && pnpm build` +Expected: all pass. This is the gate for the whole migration. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "chore: remove Railway apps, BullMQ and Redis + +apps/api and apps/worker are now served by apps/web. Redis had exactly +one consumer (BullMQ) and is deleted with it." +``` + +--- + +### Task 14: Supabase project and Data API hardening + +This task is operational rather than code. It must complete before any deployment is pointed at the new database. + +**Interfaces:** +- Consumes: nothing +- Produces: `DATABASE_URL`, `DIRECT_URL` for the Vercel environment + +- [ ] **Step 1: Create the Supabase project** + +- [ ] **Step 2: Disable the Data API for the `public` schema** + +Spec S1. Railway Postgres had no HTTP surface. Supabase exposes PostgREST at `/rest/v1` publicly, and the restored schema has no row-level security, so every table would be readable by any holder of the anon key. Disable the Data API in project settings before the application writes anything. + +- [ ] **Step 3: Verify from outside the network** + +```bash +curl -s "https://.supabase.co/rest/v1/users?select=*" \ + -H "apikey: " | head +``` + +Expected: an error or empty result, never row data. If rows come back, stop and fix before continuing. + +- [ ] **Step 4: Create the schema** + +Run: `DIRECT_URL= pnpm db:migrate` +Expected: tables created. Restore no data, per the spec's Database section. + +- [ ] **Step 5: Record `repository_settings` from Railway** + +Check whether `pruningDays` or `ignoredPaths` differ from the defaults in `packages/shared/src/constants/index.ts`. If so, note them for manual re-entry after cutover. + +--- + +### Task 15: Deploy and cut over + +- [ ] **Step 1: Create the Vercel project and set environment variables** + +Set every variable from the spec's environment table. Generate a new `SESSION_SECRET` rather than reusing the Railway value, per spec S5. + +- [ ] **Step 2: Deploy a preview** + +Run: `vercel deploy` + +- [ ] **Step 3: Add Vercel Firewall rate limit rules** + +Spec S3. Rules on `/api/webhooks/github` and `/api/auth/*`. On Vercel, a request flood is a billed invocation rather than wasted CPU, which is the inverse of the Railway situation. + +- [ ] **Step 4: Point a second GitHub App at the preview URL and verify end to end** + +Install on a throwaway repository. Push a commit. Open a pull request. Confirm the check run appears and the push notification arrives. Inspect the runs with `npx workflow inspect runs --backend vercel --project --team crod`. + +- [ ] **Step 5: Flip the production GitHub App webhook and OAuth callback URLs** + +- [ ] **Step 6: Sign in, reinstall the App, and verify the rebuild** + +Confirm the pipeline reconstructs the same branches and overlaps that Railway is still serving. This comparison is the acceptance test for the entire migration. + +- [ ] **Step 7: Re-enter `repository_settings` and re-enable browser notifications** + +- [ ] **Step 8: Tear down the Railway project** diff --git a/docs/superpowers/specs/2026-08-12-vercel-cutover-runbook.md b/docs/superpowers/specs/2026-08-12-vercel-cutover-runbook.md new file mode 100644 index 0000000..3d44d71 --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-vercel-cutover-runbook.md @@ -0,0 +1,135 @@ +# Vercel Cutover Runbook + +Date: 2026-08-12 +Companion to: `2026-08-12-vercel-migration-design.md` +Status: code complete and reviewed; these steps are not yet done + +All code for the migration is merged on `feat/vercel-migration`. +Everything in this document is operational work on live accounts, which is why it was not automated. + +Each risk below was found during implementation or review, not anticipated in the original design. +They are ordered by when they bite, and the ones marked REQUIRED will cause a silent failure if skipped. + +## Before touching anything + +Record the current `repository_settings` row from Railway. + +The migration deliberately restores no data (see the design's Database section), because almost every table is a rebuildable cache of GitHub state. +`repository_settings` is the exception: `pruningDays` and `ignoredPaths` are user-configured and reconstructible from nothing. +If they differ from the defaults in `packages/shared/src/constants/index.ts`, write them down now and re-enter them after cutover. + +## Database (Neon) + +Neon was chosen over Supabase after pricing them against this app. +Supabase's compute credit was already fully consumed by another project, so a second project was $10/month of genuinely new spend - which would have left this migration $5/month WORSE than the idle Railway worker it set out to remove. + +Neon's free tier covers this workload with room to spare: + +| | | +| --- | --- | +| Storage | 0.5 GB per project (this app's entire dataset is a few hundred KB) | +| Compute | 100 CU-hours per project per month | +| Scale-to-zero | after 5 minutes idle | + +CU-hours are compute-units multiplied by hours, not wall-clock hours. +At the smallest compute size (0.25 CU) the allowance is roughly 400 wall-clock hours of active database per month, about 13 hours a day. +The cron schedule alone accounts for around 12.5 hours a month: five wake-ups a day, each holding the compute up for about five minutes before it idles back down. + +**Watch the failure mode, because it is the opposite of Supabase's.** +Supabase free pauses a project after 7 days of INACTIVITY, which would have been a bad fit for an app that can legitimately sit quiet for a week. +Neon free suspends compute when the quota is EXHAUSTED, and it stays suspended until the next billing month. +That is the better shape for this app, but it is still a hard outage, so set a billing alert rather than discovering it from a silent webhook. + +**Pooled and direct endpoints differ by HOSTNAME on Neon, not by port.** +This is the detail most likely to be got wrong, because the widely-documented Supabase shape distinguishes them by port instead. + +- `DATABASE_URL` is the POOLED endpoint, with a `-pooler` suffix: `ep-xxx-pooler.region.aws.neon.tech` +- `DIRECT_URL` is the DIRECT endpoint, no suffix: `ep-xxx.region.aws.neon.tech` + +Both are on 5432. +Pointing both at the same endpoint appears to work and then fails later: migrations need the direct one for DDL and session state, and the app needs the pooled one. + +Create the schema: + +```bash +DIRECT_URL= pnpm db:migrate +``` + +**Note what this choice removes.** +The original plan carried a REQUIRED first step to close Supabase's Data API, because Supabase serves PostgREST publicly with no row-level security on a freshly migrated schema, and `webhook_events` holds complete GitHub payloads. +Neon exposes no such HTTP surface, so that entire risk is gone rather than mitigated. +Two of the three silent-failure risks below remain. + +**REQUIRED: confirm the unique constraint on `webhook_events.delivery_id` actually exists afterwards.** +The entire idempotency design rests on it. +`onConflictDoNothing` against a missing constraint does not error, it simply never conflicts, so every GitHub redelivery would start a duplicate billed workflow run. +The constraint is declared in `packages/db/drizzle/0000_woozy_prima.sql`, so a clean migrate creates it; verify rather than assume. + +`.env.example` ships `localhost:5432` in both slots for local development, where one Postgres serves both. +Copy-pasting that shape into production leaves the app on a direct connection and migrations on a pooled one, which is the exact inversion of what each needs. + +## Vercel + +**REQUIRED: check where `vercel.json` needs to live.** + +It currently sits at the repo root and contains only the `crons` array. +If the project's Root Directory is set to `apps/web`, a repo-root `vercel.json` is never read, and **both cron jobs silently never fire**. +There is no error; branch pruning and event cleanup simply stop happening. +Confirm the Root Directory in project settings and move the file into `apps/web/` if that is where the project is rooted. + +**Environment variables.** Set everything in `.env.example`, plus: + +- `SESSION_SECRET` must be a NEW value, not the Railway one. Rotating it guarantees every previously issued cookie fails closed. +- `CRON_SECRET` fails closed. If unset, both cron endpoints return 401 forever with no other symptom. +- `VAPID_SUBJECT` must be the real production domain. The placeholder is deliberately non-functional. + +**REQUIRED: add Vercel Firewall rate limit rules on `/api/webhooks/github` and `/api/auth/*`.** + +The old `@fastify/rate-limit` was dropped with no in-code replacement, because its in-memory store was already per-instance and not delivering the guarantee it appeared to. +On Railway a request flood wasted CPU you had already paid for. +On Vercel every request is a billed invocation, so the same flood is a direct bill. +`/api/webhooks/github` is the worst exposure: public, unauthenticated, and it computes an HMAC on every request before it can reject one. + +## GitHub App + +**REQUIRED: both URLs changed PATH, not just host.** + +- Webhook: `/webhooks/github` becomes **`/api/webhooks/github`** +- OAuth callback: `/auth/github/callback` becomes **`/api/auth/github/callback`** + +A host-only flip produces a 404 on every delivery, and an OAuth `redirect_uri` mismatch on every sign-in, because `apps/web/src/routes/api/auth/github.ts` sends the new path as `redirect_uri`. + +## Verification, in order + +Do this against a preview deployment with a second GitHub App on a throwaway repository, before touching production. + +1. Sign in end to end. This is the one path never exercised by an automated test, and it is where the last defect of the migration was found. +2. Push a commit. Confirm the check run appears. +3. Open a pull request. Confirm the push notification arrives. +4. Inspect the runs: `npx workflow inspect runs --backend vercel --project --team crod` +5. **Measure cold-start latency against GitHub's 10 second webhook timeout.** The route does a repository lookup, an insert, `start()`, and an update before responding, which is four database round trips rather than one. On Neon this compounds with scale-to-zero: the first delivery after an idle period pays the compute wake-up before any of those four run. Measure a genuinely cold delivery, not a warm one. + +Then flip production, sign in, reinstall the App on the real repository, and confirm the pipeline reconstructs the same branches and overlaps Railway is still serving. +That comparison is the acceptance test for the whole migration. +Railway stays untouched until it passes, so rollback is repointing one URL. + +## Operating notes + +**Recovering a failed delivery.** GitHub's Redeliver button is now curative: a delivery whose run failed terminally (`error` set, `processedAt` null) or never dispatched (`dispatched_at` null) will start a fresh run on redelivery. A delivery already in flight or already processed will not restart. This is the only operator recovery lever, so it is worth knowing it exists. + +**Failure blast radius is wider than BullMQ's.** A terminal `postCheckRun` or `sendPush` failure now leaves `webhook_events.processed_at` NULL and sets `error`, where previously a feedback-job failure never touched that row. That is the correct consequence of making a delivery one durable unit. + +**Every delivery costs a workflow run**, including event types the workflow classifies as unhandled. This matches the old behavior, but on Vercel it is billed. Keep the GitHub App's event subscriptions tight. + +**Known limitations, all accepted deliberately:** + +- Check-run idempotency is narrowed, not closed. A crash between the GitHub call returning and the `pr_alerts` write can still produce one duplicate check run. +- Redelivery dedup is narrowed, not closed, for the same structural reason: `@workflow/core` exposes no start-time idempotency key, so there is no atomic link between "durable run created" and "local row updated". +- `processWebhook` renders as an empty graph in workflow visualizations, because the static graph builder emits an empty DAG for any workflow containing try/catch. Execution is unaffected; the manifest is diagnostics-only. Durable failure bookkeeping was judged worth more than the picture. + +## Follow-ups worth a ticket, none blocking + +- The repository has no CI. `pnpm test:integration` is the only proof the migration's central ordering property holds, and nothing runs it on push. +- Observability regressed: the pino logger is gone with `apps/api`, and `apps/web/src/routes/api/health.ts` swallows its database error with a bare `catch {}`. +- `apps/web/src/routes/__root.tsx` references `/favicon.ico`, which has never existed in `apps/web/public/`. Pre-existing, 404s on every page load. +- The branch-deletion cleanup resolves overlaps to `resolved` immediately before deleting the branch rows, which cascade-delete them anyway. The update is inert and its comment overstates what it does. diff --git a/docs/superpowers/specs/2026-08-12-vercel-migration-design.md b/docs/superpowers/specs/2026-08-12-vercel-migration-design.md new file mode 100644 index 0000000..e56978d --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-vercel-migration-design.md @@ -0,0 +1,569 @@ +# Vercel Migration Design + +Date: 2026-08-12 +Status: Implemented. Superseded in one respect, see the amendment below. + +## Amendment, 2026-08-13: the database provider is Neon, not Supabase + +This document was written against Supabase and implemented that way in every respect that touches code, which is to say hardly any: `packages/db` takes a pooled Postgres connection string and nothing in the branch is provider-specific. + +The provider changed at cutover time for a cost reason the design did not anticipate. +Supabase's monthly compute credit was already fully consumed by another project in the same account, so adding this one was $10/month of genuinely new spend. +Since the entire motivation for this migration was removing a $5/month always-on Railway worker, that would have left the project $5/month worse off than doing nothing. +Neon's free tier absorbs this workload with room to spare. + +Two consequences for the sections below: + +- **S1 no longer applies and is retained only as a record.** It required closing Supabase's public Data API, which does not exist on Neon. That risk is eliminated rather than mitigated. +- **The pooled and direct connection strings differ by hostname, not port.** Neon's pooled endpoint carries a `-pooler` suffix; both use 5432. Wherever this document says port 6543, read "the `-pooler` hostname" instead. + +`docs/superpowers/specs/2026-08-12-vercel-cutover-runbook.md` is the operational document and is correct as written. +Everything else here stands. + +## Summary + +Move Overlap off Railway and onto Vercel + Supabase. +Collapse three deployed apps into one Vercel project. +Replace the always-on BullMQ worker with Vercel Workflow DevKit (WDK) durable workflows. +Delete Redis entirely. + +## Motivation + +Railway bills per second for running services, and the BullMQ worker is always-on by design. +For a low-traffic app that means paying continuously for an idle process. + +The Vercel account is already on a paid Pro plan, and Supabase is already paid for. +Adding Overlap to both costs approximately zero marginal spend at current traffic, because Pro's included allowances far exceed what this app consumes. +The migration therefore removes new spend rather than shifting it. + +A secondary motivation is correctness. +The current job graph contains a race condition papered over with a fixed delay, and two error handlers that silently corrupt data. +Both are fixed as part of this work rather than carried across. + +## Current architecture + +Three Railway services plus two Railway databases. + +| Component | Implementation | +| --- | --- | +| `apps/web` | TanStack Start SSR on Nitro, Vite 7, deployed via Dockerfile | +| `apps/api` | Fastify 5, five route groups, BullMQ producer, job scheduler | +| `apps/worker` | Six always-on BullMQ `Worker` instances with per-queue concurrency and rate limiters | +| Postgres | Railway-provisioned, accessed through Drizzle ORM and `postgres-js` | +| Redis | Railway-provisioned, used only as BullMQ's backing store | + +### Verified: Redis has exactly one consumer + +Redis is referenced in five files, and every reference is BullMQ. + +- `apps/api/src/queues/index.ts` - queue connection +- `apps/api/src/scheduler.ts` - repeatable job scheduler +- `apps/worker/src/index.ts` - worker connection +- `apps/worker/src/processors/webhook-events.ts` - opens its own connection to enqueue follow-on jobs +- `apps/worker/src/processors/overlap-detection.ts` - same + +`apps/api/src/routes/health.ts` pings Redis, but only to confirm BullMQ is reachable. + +Sessions do not use Redis. +`apps/api/src/plugins/auth.ts` uses a signed cookie plus a Postgres lookup. +Rate limiting does not use Redis either. +`@fastify/rate-limit` is registered with its default in-memory store. + +Redis is therefore not a separate decision. +It has one consumer, and this design replaces that consumer. + +### Current job graph + +``` +webhook POST /webhooks/github + |- inline: verify HMAC, store webhook_events row, handle branch deletion + \- enqueue webhook_events + |- enqueue branch_sync + |- enqueue overlap_detection (delay: 5000) + \- enqueue maintenance:sync_repository (on installation events) + +overlap_detection + |- enqueue github_feedback (per open PR) + \- enqueue push_notification +``` + +## Target architecture + +One Vercel project. +`apps/web` absorbs both the API and the worker. + +``` +apps/web/ + src/routes/ UI routes, unchanged + src/routes/api/ former apps/api, as TanStack Start server routes + src/workflows/ former apps/worker, as WDK workflows and steps +packages/db survives, pooler connection string and prepare:false +packages/github survives untouched +packages/shared survives, minus QUEUE_NAMES and RATE_LIMITS +``` + +Removed from the repository: + +- `apps/api` and `apps/worker` as deployed apps +- `apps/web/Dockerfile`, `apps/api/Dockerfile`, `apps/worker/Dockerfile` +- `railway.json` at the root and in all three apps +- `bullmq` and `ioredis` dependencies +- the Redis service in `docker-compose.yml` +- the `REDIS_URL` environment variable + +## Design + +### Workflow decomposition + +Each GitHub delivery becomes one durable workflow run. + +```ts +export async function processWebhook(deliveryId: string) { + "use workflow" + + const evt = await loadEvent(deliveryId) + + if (evt.type === "push") { + const branchId = await upsertBranch(evt) + await syncBranchFiles(branchId, evt) + const results = await detectOverlaps(branchId) + for (const n of results.notifications) { + await postCheckRun(n) + await sendPush(n) + } + } + + // pull_request and installation branches follow the same shape +} +``` + +Every named function above is a `"use step"` function. +The workflow function itself only orchestrates, so it never touches Node APIs and never hits the workflow sandbox restrictions. + +Step functions map one to one onto the existing processors: + +| Current processor | Becomes | +| --- | --- | +| `webhook-events.ts` | `loadEvent`, `upsertBranch`, `upsertPullRequest`, `syncInstallation` | +| `branch-sync.ts` | `syncBranchFiles` | +| `overlap-detection.ts` | `detectOverlaps` | +| `github-feedback.ts` | `postCheckRun` | +| `push-notification.ts` | `sendPush` | +| `maintenance.ts` | `getActiveRepositoryIds`, `pruneRepositoryBranches`, `cleanupOldEvents`, `syncRepository` | + +### The five-second delay is deleted + +`apps/worker/src/processors/webhook-events.ts:150` reads: + +```ts +delay: 5000, // Small delay to ensure sync completes first +``` + +Branch sync and overlap detection are separate BullMQ queues with no ordering guarantee between them. +The delay is a hope, not a constraint. +If `syncBranchFiles` takes longer than five seconds on a large diff or a slow GitHub response, detection reads a stale file list and computes incorrect overlaps. +If it finishes in 200ms, the pipeline stalls for 4.8 seconds for no reason. + +In the workflow, `await syncBranchFiles(...)` followed by `await detectOverlaps(...)` is a durable happens-before edge. +The delay is removed. +Correctness improves and median latency drops by roughly five seconds. + +This is the primary reason WDK was chosen over Vercel Queues. +Two queue topics would have reproduced the same race that two BullMQ queues have today. + +### Idempotency + +BullMQ's `jobId` deduplication disappears and is replaced at the front door, using the unique constraint that already exists on `webhook_events.deliveryId`. + +HMAC verification is a hard precondition on everything below. +No database write and no workflow start may occur before the signature is confirmed valid. + +```ts +const raw = await request.text() // raw bytes, before any JSON parse +const sig = request.headers.get("x-hub-signature-256") + +const verification = verifyWebhookSignature(raw, sig, process.env.GITHUB_WEBHOOK_SECRET) +if (!verification.valid) { + return new Response(JSON.stringify({ error: "Invalid signature" }), { status: 401 }) +} + +const payload = JSON.parse(raw) + +const [row] = await db.insert(webhookEvents) + .values({ deliveryId, eventType, payload, repositoryId }) + .onConflictDoNothing() + .returning() + +if (!row) return Response.json({ received: true }) // GitHub redelivery + +await start(processWebhook, [deliveryId]) +``` + +Ordering matters and is not stylistic. +Inverting it produces a public unauthenticated endpoint that writes attacker-controlled JSON into `webhook_events` and starts a billed workflow run per request. + +This is stronger than the current behaviour. +Deduplication state lives durably in Postgres rather than expiring out of Redis on a TTL. + +Within a run, WDK memoizes completed step results across replays, so a retry after a partial failure does not re-post a check run or re-send a push notification. + +Three existing workarounds are deleted rather than ported: + +- The `jobSuffix` timestamp in `overlap-detection.ts:196`, added specifically to defeat deduplication for reactivated overlaps +- The `jobId` built from `repo.id`, `branchId` and `Date.now()` in `webhook-events.ts:148`, a deduplication key containing a timestamp, which deduplicates nothing +- commit `bb92e78`, "replace colons in BullMQ job IDs to fix notification delivery" + +Each is a symptom of working around BullMQ's deduplication semantics. +None are needed once identity is owned by the database. + +### Rate limiting + +`RATE_LIMITS` in `packages/shared/src/constants/index.ts:20-27` defines proactive per-queue caps. +Those caps require shared cross-instance state, which is what Redis provided. + +They are replaced with reactive backoff driven by GitHub's own signal. + +```ts +catch (err) { + if (err.status === 429 || err.status === 403) { + throw new RetryableError("GitHub rate limited", { + retryAfter: err.headers?.["retry-after"] ?? "5m", + }) + } + throw new FatalError(err.message) +} +``` + +The proactive caps were set below GitHub's actual budget. +`BRANCH_SYNC` was capped at 30 per minute against an installation limit of 5000 per hour, roughly 83 per minute. +At current traffic these limiters are very unlikely to have ever fired. + +Reacting to GitHub's `Retry-After` header responds to the real budget rather than a hardcoded estimate of it, and requires no shared state. +`RATE_LIMITS` and `QUEUE_NAMES` are deleted from `packages/shared`. + +### Error handling + +| Condition | Behaviour | +| --- | --- | +| GitHub 403 or 429 | `RetryableError` with `retryAfter` from the response header, defaulting to `5m` | +| GitHub 5xx or network failure | `RetryableError` | +| GitHub 4xx other than 403/429 | `FatalError` | +| Missing repository or branch row | `FatalError` | + +#### Bug fixed in transit: silent data loss on transient GitHub failure + +`apps/worker/src/processors/branch-sync.ts:52-56`: + +```ts +} catch (error) { + console.error(`Failed to fetch branch files: ${error}`) + changedFiles = [] +} +``` + +Execution then continues unconditionally to delete every `branchFiles` row for the branch and insert the empty list. + +A transient GitHub 500 therefore wipes the branch's file index. +The next `detectOverlaps` run finds no files, reports zero overlaps, and marks genuine active overlaps as `resolved`. +Overlaps disappear from the UI with no error surfaced anywhere. + +Under the mapping above this throws `RetryableError`, so the delete never executes and the step is retried. + +`apps/worker/src/processors/github-feedback.ts:110-113` swallows `createCheckRun` failures in the same shape and receives the same treatment. + +### API fold-in + +The five Fastify route groups become TanStack Start server routes under `apps/web/src/routes/api/`. + +- **Raw body for HMAC.** + The webhook route calls `await request.text()` before any JSON parsing, so the exact bytes GitHub signed are available to `verifyWebhookSignature`. + The `addContentTypeParser` workaround in `webhooks.ts:19-26` is removed. + +- **CORS deleted.** + Web and API become same-origin. + `@fastify/cors` is removed, along with the `VITE_API_URL` and `API_URL` environment variables. + Session cookies become same-origin, so the `credentials: true` cross-origin handling is no longer needed. + +- **Session cookies.** + `@fastify/cookie`'s `signCookie` and `unsignCookie` are replaced with `jose` JWTs in an httpOnly cookie. + Existing sessions are invalidated, so every user signs in once more at cutover. + This was accepted rather than reproducing the `@fastify/cookie` signature format byte for byte, because the domain changes at cutover regardless. + +- **Request rate limiting.** + `@fastify/rate-limit` has no direct replacement and is dropped. + It used the default in-memory store, so its 100-per-minute cap already applied per instance rather than globally, and was not delivering the guarantee it appeared to. + Vercel Firewall rate limiting covers this at the edge if needed. + Recorded here as a deliberate removal rather than an oversight. + +### Scheduled work + +`apps/api/src/scheduler.ts` and its two BullMQ job schedulers are deleted, replaced by Vercel Cron. + +```json +{ + "crons": [ + { "path": "/api/cron/prune-branches", "schedule": "0 */6 * * *" }, + { "path": "/api/cron/cleanup-events", "schedule": "0 3 * * *" } + ] +} +``` + +Both endpoints verify the `CRON_SECRET` header before doing anything. + +Each endpoint calls `start()` on a workflow rather than performing the work inline. +The prune workflow fans out over every active repository: `getActiveRepositoryIds` returns the id list as one step, and `pruneBranchesWorkflow` awaits one `pruneRepositoryBranches` step per id, so the work scales with the number of installations across many bounded step invocations rather than inside a single one. + +Vercel Pro supports minute-level cron granularity, so the existing six-hour schedule is preserved exactly. + +### Database + +A new Supabase project on the existing paid plan. + +#### No data is migrated + +`prod_dump.sql` is not restored. +The schema is created by running `pnpm db:migrate` against the empty Supabase project, and the application repopulates itself. + +This is possible because almost the entire database is a materialized cache of GitHub state rather than authoritative data. + +| Table | Rows in prod | Classification | +| --- | --- | --- | +| `webhook_events` | 51 | Audit log, deleted after 7 days by `cleanupOldEvents` | +| `github_app_installations` | 7 | Re-fetched by `syncUserInstallations` on next login | +| `branches` | 3 | Rebuilt by `syncRepository` | +| `branch_files` | 2 | Rebuilt by `syncBranchFiles` | +| `push_subscriptions` | 2 | Authoritative, but re-created by one click per browser | +| `overlaps`, `overlap_files` | 1, 1 | Recomputed from scratch on every `detectOverlaps` run | +| `repositories` | 1 | Rebuilt by `syncRepository` | +| `repository_settings` | 1 | Authoritative, hand-carried if customized from defaults | +| `user_installations` | 1 | Re-fetched by `syncUserInstallations` | +| `users` | 1 | Re-created on next sign-in via GitHub OAuth | +| `organizations`, `pr_alerts`, `pull_requests` | 0 | Empty | + +Rebuilding rather than restoring is chosen for three reasons beyond the trivial data volume. + +**It reduces the S1 exposure to a hardening task.** +An empty schema behind an exposed Data API is a configuration to fix before data accumulates, rather than a live exposure of production records. +S1 remains required regardless. + +**It functionally verifies the migration.** +If a fresh install reconstructs the same repository, branches and overlaps that Railway currently serves, the entire new workflow pipeline has been proven end to end by the act of migrating. +Restoring a dump would demonstrate nothing about whether the workflows execute correctly. + +**It does not carry forward known corruption.** +The `branch-sync` defect documented above wipes a branch's file index on any transient GitHub failure, after which `detectOverlaps` marks genuine overlaps as `resolved`. +Production rows may already contain that damage with no record of it. +A dump preserves the damage. +A rebuild from GitHub eliminates it. + +Manual carry-over is limited to `repository_settings`, and only if `pruningDays` or `ignoredPaths` were changed from the defaults in `packages/shared/src/constants/index.ts:33-45`. +Check before cutover and re-enter through the UI if so. + +`prod_dump.sql` is retained locally as a rollback reference only. +It is already covered by `.gitignore` and must not be committed. + +#### Connection configuration + +Changes to `packages/db/src/client.ts`: + +- `DATABASE_URL` points at the Supavisor transaction pooler on port 6543, not the direct connection (see the 2026-08-13 amendment at the top of this document: on Neon this is the `-pooler` hostname instead, both on port 5432). + Serverless functions open connections per invocation and will exhaust a direct Postgres connection limit. +- The query client is constructed with `prepare: false`. + Transaction-mode pooling cannot support prepared statements, and `postgres-js` uses them by default. + Without this the app fails intermittently under concurrency with `prepared statement "s1" already exists`. +- `migrationClient` uses a separate `DIRECT_URL` on port 5432, because migrations require session mode. + +### Environment variables + +| Variable | Change | +| --- | --- | +| `REDIS_URL` | Removed | +| `API_URL` | Removed, same-origin | +| `VITE_API_URL` | Removed, same-origin | +| `DATABASE_URL` | Repointed to the Neon POOLED endpoint (`-pooler` hostname suffix) | +| `DIRECT_URL` | New, Neon DIRECT endpoint (no `-pooler` suffix), migrations only | +| `CRON_SECRET` | New, guards the two cron endpoints | +| `APP_URL` | Repointed to the Vercel domain | +| `VAPID_SUBJECT` | Repointed off the `.up.railway.app` default in `.env.example` | +| `GITHUB_*` | Unchanged in value, moved to Vercel environment variables | +| `SESSION_SECRET` | Rotated to a new value at cutover, used as the `jose` signing key, see S5 | + +## Security + +A design-level threat model was run against this plan on 2026-08-12. + +Most findings below are not defects in the current code or the proposed code. +They are safety properties that the Railway deployment was providing implicitly, and that this migration retires without any file changing to announce it. +They are recorded here because a diff-based security review of the implementation cannot find them. + +### S1. Disable the Supabase Data API for the public schema + +**SUPERSEDED by the 2026-08-13 amendment at the top of this document.** +The provider is Neon, which exposes no PostgREST surface, so there is nothing here to close. +The section is kept in full because the reasoning applies again immediately if anyone moves this app to Supabase, and because it records why the risk was considered rather than leaving a future reader to rediscover it. + +Railway Postgres is reachable only over the Postgres wire protocol. +A Supabase project additionally exposes PostgREST at `/rest/v1` on the public internet, authenticated by an anon key that is public by design. + +Running `pnpm db:migrate` places every table in the `public` schema with no row-level security, because none was ever needed on Railway. +The combination of default grants, an exposed Data API, and no RLS makes `users`, `repositories`, `branches`, `overlaps` and `webhook_events` readable by any holder of the anon key. +`webhook_events` stores complete GitHub payloads, so it is the highest-value target in the database. + +Because no data is migrated (see the Database section), the schema is empty at cutover and this is a hardening task rather than a live exposure. +It is still required, and must be done before the application begins repopulating the tables, because the window between first sign-in and remembering to close the Data API is exactly when real data appears. + +Required, in order of preference: + +1. Disable the Data API for the `public` schema in Supabase project settings +2. Or restore into a schema that is not exposed by PostgREST +3. Or enable RLS on every table with no policies attached, so access fails closed + +The application connects directly over the Postgres protocol through Drizzle and never uses PostgREST, so disabling it costs nothing. +This must be verified before the application is pointed at the project, not after. + +### S2. Webhook signature verification ordering + +See the code block in the idempotency section above. +Verification of the raw request body precedes every database write and every workflow start. +This is a required ordering, not a stylistic preference. + +### S3. Rate limiting removal changes the threat economics + +On Railway, flooding an endpoint consumed CPU that was already paid for. +On Vercel every request is a billed invocation, so the same flood becomes a direct financial cost. + +Exposure, ranked: + +- `/api/webhooks/github` is public and unauthenticated, and computes an HMAC on every request before it can reject one. + A signature cannot be forged, but an attacker can force payment for each failed verification. +- `/api/auth/github/callback` performs an outbound GitHub token exchange per request. +- `/api/push/subscribe` is authenticated but places no bound on subscription rows per user. + +Required: + +- Vercel Firewall rate limit rules on `/api/webhooks/github` and `/api/auth/*` +- A per-user cap on rows in `push_subscriptions` + +### S4. The JWT replaces the signature mechanism, not the session model + +The current session cookie carries `{ userId }` and every request re-reads the user from Postgres at `apps/api/src/plugins/auth.ts:51-54`. +Revocation is therefore immediate. +Deleting the user row ends the session on the next request. + +Moving to `jose` invites putting profile claims in the token and skipping that lookup. +Doing so trades immediate revocation for a seven-day window in which a deleted user remains authenticated. + +Required: + +- The token carries `userId` and nothing else +- The per-request database lookup is retained exactly as it is today +- The verification algorithm is pinned explicitly rather than inferred from the token header +- `exp` is set to seven days, matching the current cookie `maxAge` +- `httpOnly: true`, `secure: true` in production, and `sameSite: "lax"` are preserved + +### S5. Rotate `SESSION_SECRET` at cutover + +Sessions are invalidated by the migration regardless. +Issuing a new secret guarantees that every previously issued cookie fails closed, rather than relying on a format mismatch to reject it. + +### S6. `CRON_SECRET` requires a timing-safe comparison + +Vercel cron endpoints are publicly routable. +`/api/cron/prune-branches` starts a workflow that iterates every active repository, so an externally triggerable cron endpoint amplifies both cost and database load. + +Compare using `crypto.timingSafeEqual` over equal-length buffers. +A `===` comparison leaks the secret through response timing. + +### S7. Keep the OAuth state cookie integrity-protected + +The `oauth_state` cookie at `apps/api/src/routes/auth.ts:22-30` is currently signed, and the callback validates the unsigned value against the returned `state` parameter. +The replacement must verify integrity, not merely check presence. +A presence-only check reduces the CSRF protection to a value the attacker supplies. + +### S8. Do not pass webhook payloads into workflow arguments + +`start(processWebhook, [deliveryId])` deliberately passes only an identifier. +Full GitHub payloads, which include repository names, commit messages and author email addresses, stay in Postgres and are never copied into workflow run storage. + +This is a privacy property of the design. +Passing the payload directly would be a simplification that widens data residency without any corresponding benefit. + +### S9. `/health/ready` must drop its Redis check + +`apps/api/src/routes/health.ts:26-31` pings Redis through `fastify.queues`. +After migration that object does not exist, the check throws, and the endpoint returns 503 permanently. +The `redis` key is removed from the checks object. + +### Verified clean, and one boundary that is not + +- **Repository authorization, route layer.** + Every `/:id` route calls `requireUser` and then `requireRepoAccess` before reading or writing. + No insecure direct object references were found at the route layer: a request cannot reach one repository's data by supplying another repository's id. + + This is narrower than "repository authorization" sounds, and the narrowness matters. `requireRepoAccess` (`apps/web/src/lib/repo-access.ts`) scopes access by GitHub App **installation**, not by the requesting user's actual per-repository access on GitHub. + `syncUserInstallations` (`apps/web/src/lib/github-oauth.ts:3-50`) links a user to an installation as soon as GitHub's `/user/installations` returns it for them, which happens once the user can reach at least one repository inside that installation. + `applyInstallationRepositories` and `syncInstallation` (`apps/web/src/workflows/steps.ts:450-576`, `578-`) then populate `repositories` rows for **every** repository GitHub reports for that installation, with no per-user filter. + + Failure scenario: an organization installs the GitHub App across the whole org, an engineer has push access to exactly one repository in it, they sign in, and `requireRepoAccess` now passes for every repository in that installation - including `/api/repositories/$id/diffs`, which uses the installation token to fetch full patch content the requesting user was never individually granted access to on GitHub. + + A second gap compounds this: `syncUserInstallations` only inserts and updates `userInstallations` rows; it never removes one. A user removed from an organization, or from just the one repository that originally qualified them for the installation, keeps access until the entire installation is uninstalled from GitHub - there is no re-sync path that revokes a single user's access. + + This is pre-existing behavior, faithfully ported from `apps/api`, not something this migration introduces or worsens. It is recorded here, unfixed, because a diff-based review of the migration would see `requireRepoAccess` called correctly everywhere and conclude the tenant boundary is sound. It is not: the boundary this code actually enforces is per-installation, not per-repository-per-user. Fixing it is a separate decision - it requires either checking live GitHub permissions per request or storing per-user repository grants - and is out of scope here. + +- **CSRF exposure after the same-origin fold-in.** + Removing the CORS boundary retires a control that was gating cross-origin state changes. + An audit of the route table found every mutation is POST, PATCH or DELETE and every GET is a read. + With `sameSite: "lax"` preserved, cross-site requests do not carry the session cookie to those methods. + The residual requirement is narrow: preserve `sameSite: "lax"`, and introduce no state-changing GET routes. + +## Cutover plan + +Railway stays running and untouched through step 6. +Rollback at any point before step 7 is repointing a single URL. + +1. Create the Supabase project. + Disable the Data API for the `public` schema per S1. + Run `pnpm db:migrate` to create the schema. + Restore no data. + Confirm from outside the network that `/rest/v1` returns no table data when presented with the anon key. +2. Record the current `repository_settings` row from Railway if it differs from the defaults, for manual re-entry later +3. Deploy the migrated code to a Vercel preview deployment +4. Create a second GitHub App pointed at the preview URL +5. Install it on a throwaway repository and verify the full path: push a commit, open a pull request, confirm the check run appears and the push notification arrives +6. Flip the production GitHub App webhook URL and OAuth callback URL to the Vercel domain. + Both the host and the path change, not only the host: the webhook URL becomes `https:///api/webhooks/github` (was `/webhooks/github` on Railway), and the OAuth callback URL becomes `https:///api/auth/github/callback` (was `/auth/github/callback` on Railway). + The callback path matters even if only the host is updated by habit: `apps/web/src/routes/api/auth/github.ts` sends `redirect_uri=/api/auth/github/callback` in the authorize request, so a GitHub App still registered with the old `/auth/github/callback` path produces a `redirect_uri` mismatch and every sign-in fails. +7. Sign in, reinstall the App on the real repository, and confirm the pipeline reconstructs its branches and overlaps. + Compare against what Railway is still serving. + This comparison is the acceptance test for the whole migration. +8. Re-enter `repository_settings` if step 2 recorded a difference, and re-enable browser notifications +9. Tear down the Railway project + +## Out of scope + +- WDK hooks, streams, and `DurableAgent`. + Nothing in this pipeline waits on human input or a language model. +- Vercel Queues. + Evaluated and rejected, see the delay-deletion section. +- Any realtime transport. + The UI keeps its existing React Query `staleTime` polling. +- Any feature work. + This is a migration. + The two bug fixes are included only because carrying the bugs across would mean deliberately porting known data-loss behaviour. + +## Risks + +- **WDK maturity.** + The largest bet in this design. + Mitigated by the parallel-run cutover, which keeps Railway serving production until the Vercel path is verified against real deliveries. +- **GitHub webhook timeout.** + GitHub expects a response within 10 seconds. + The route only inserts one row and calls `start()`, which returns immediately, so this should be comfortable. + Cold-start latency must be measured during step 4 of the cutover rather than assumed. +- **`detectOverlaps` runtime.** + `overlap-detection.ts:73-100` is O(branches x files) within a single step. + Acceptable at current scale. + A repository with hundreds of concurrently live branches would require splitting that step, and Fluid Compute's 800-second ceiling is the hard bound. +- **Supabase pooler behaviour under burst.** + Transaction-mode pooling changes connection semantics. + The `prepare: false` requirement is the known issue, but pooler limits should be watched during the parallel run. diff --git a/package.json b/package.json index de660d5..d200e10 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,15 @@ "name": "overlap", "version": "0.1.0", "private": true, + "type": "module", "description": "Detect overlapping file changes across active Git branches in real time", "scripts": { "dev": "turbo dev", "build": "turbo build", "lint": "turbo lint", "typecheck": "turbo typecheck", + "test": "turbo test", + "test:integration": "turbo test:integration", "clean": "turbo clean && rm -rf node_modules", "db:generate": "turbo db:generate --filter=@overlap/db", "db:migrate": "turbo db:migrate --filter=@overlap/db", @@ -15,11 +18,13 @@ "db:studio": "turbo db:studio --filter=@overlap/db" }, "devDependencies": { + "@electric-sql/pglite": "0.2.17", "@types/node": "^20.11.0", "eslint": "^9.0.0", "prettier": "^3.2.0", "turbo": "^2.3.0", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "typescript-eslint": "^8.67.0" }, "packageManager": "pnpm@9.15.0", "engines": { diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index b8ca818..95daa13 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -5,6 +5,14 @@ export default defineConfig({ out: './drizzle', dialect: 'postgresql', dbCredentials: { - url: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/overlap', + // Migrations require a direct (unpooled) connection: they run DDL and rely + // on session state, which transaction pooling does not preserve. On Neon, + // pooled and direct differ by HOSTNAME, not by port - the pooled endpoint + // carries a `-pooler` suffix while the direct one does not, and both use + // port 5432. See packages/db/src/client.ts for the full explanation. + url: + process.env.DIRECT_URL || + process.env.DATABASE_URL || + 'postgresql://postgres:postgres@localhost:5432/overlap', }, }) diff --git a/packages/db/drizzle/0004_noisy_daimon_hellstrom.sql b/packages/db/drizzle/0004_noisy_daimon_hellstrom.sql new file mode 100644 index 0000000..55789e1 --- /dev/null +++ b/packages/db/drizzle/0004_noisy_daimon_hellstrom.sql @@ -0,0 +1 @@ +ALTER TABLE "webhook_events" ADD COLUMN "dispatched_at" timestamp with time zone; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0003_snapshot.json b/packages/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..92e9e4a --- /dev/null +++ b/packages/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1720 @@ +{ + "id": "296a25d0-ccf1-488f-a99b-4248bcef0bd7", + "prevId": "e8abda33-259f-4fe8-92a0-63dd9244c0c4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.branch_files": { + "name": "branch_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "branch_id": { + "name": "branch_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_type": { + "name": "change_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_files_branch_path_idx": { + "name": "branch_files_branch_path_idx", + "columns": [ + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_files_branch_id_idx": { + "name": "branch_files_branch_id_idx", + "columns": [ + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_files_file_path_idx": { + "name": "branch_files_file_path_idx", + "columns": [ + { + "expression": "file_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_files_branch_id_branches_id_fk": { + "name": "branch_files_branch_id_branches_id_fk", + "tableFrom": "branch_files", + "tableTo": "branches", + "columnsFrom": [ + "branch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.branches": { + "name": "branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sha": { + "name": "sha", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_pusher_github_id": { + "name": "last_pusher_github_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branches_repo_name_idx": { + "name": "branches_repo_name_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branches_repository_id_idx": { + "name": "branches_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branches_last_seen_at_idx": { + "name": "branches_last_seen_at_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branches_repository_id_repositories_id_fk": { + "name": "branches_repository_id_repositories_id_fk", + "tableFrom": "branches", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_app_installations": { + "name": "github_app_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "installations_installation_id_idx": { + "name": "installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_app_installations_organization_id_organizations_id_fk": { + "name": "github_app_installations_organization_id_organizations_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_app_installations_user_id_users_id_fk": { + "name": "github_app_installations_user_id_users_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_app_installations_installation_id_unique": { + "name": "github_app_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_github_id_idx": { + "name": "organizations_github_id_idx", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_github_id_unique": { + "name": "organizations_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.overlap_files": { + "name": "overlap_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "overlap_id": { + "name": "overlap_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_change_type": { + "name": "source_change_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "target_change_type": { + "name": "target_change_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "overlap_files_overlap_path_idx": { + "name": "overlap_files_overlap_path_idx", + "columns": [ + { + "expression": "overlap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "overlap_files_overlap_id_idx": { + "name": "overlap_files_overlap_id_idx", + "columns": [ + { + "expression": "overlap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "overlap_files_overlap_id_overlaps_id_fk": { + "name": "overlap_files_overlap_id_overlaps_id_fk", + "tableFrom": "overlap_files", + "tableTo": "overlaps", + "columnsFrom": [ + "overlap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.overlaps": { + "name": "overlaps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_branch_id": { + "name": "source_branch_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_branch_id": { + "name": "target_branch_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'low'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "overlaps_branches_idx": { + "name": "overlaps_branches_idx", + "columns": [ + { + "expression": "source_branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "overlaps_repository_id_idx": { + "name": "overlaps_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "overlaps_status_idx": { + "name": "overlaps_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "overlaps_severity_idx": { + "name": "overlaps_severity_idx", + "columns": [ + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "overlaps_repository_id_repositories_id_fk": { + "name": "overlaps_repository_id_repositories_id_fk", + "tableFrom": "overlaps", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "overlaps_source_branch_id_branches_id_fk": { + "name": "overlaps_source_branch_id_branches_id_fk", + "tableFrom": "overlaps", + "tableTo": "branches", + "columnsFrom": [ + "source_branch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "overlaps_target_branch_id_branches_id_fk": { + "name": "overlaps_target_branch_id_branches_id_fk", + "tableFrom": "overlaps", + "tableTo": "branches", + "columnsFrom": [ + "target_branch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_alerts": { + "name": "pr_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "overlap_id": { + "name": "overlap_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "alert_type": { + "name": "alert_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "check_run_id": { + "name": "check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_alerts_pr_overlap_idx": { + "name": "pr_alerts_pr_overlap_idx", + "columns": [ + { + "expression": "pull_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "overlap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_alerts_pull_request_id_idx": { + "name": "pr_alerts_pull_request_id_idx", + "columns": [ + { + "expression": "pull_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_alerts_overlap_id_idx": { + "name": "pr_alerts_overlap_id_idx", + "columns": [ + { + "expression": "overlap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_alerts_pull_request_id_pull_requests_id_fk": { + "name": "pr_alerts_pull_request_id_pull_requests_id_fk", + "tableFrom": "pr_alerts", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_alerts_overlap_id_overlaps_id_fk": { + "name": "pr_alerts_overlap_id_overlaps_id_fk", + "tableFrom": "pr_alerts", + "tableTo": "overlaps", + "columnsFrom": [ + "overlap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pull_requests": { + "name": "pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "github_pr_number": { + "name": "github_pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_requests_repo_number_idx": { + "name": "pull_requests_repo_number_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_requests_repository_id_idx": { + "name": "pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_requests_branch_id_idx": { + "name": "pull_requests_branch_id_idx", + "columns": [ + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_requests_state_idx": { + "name": "pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_requests_repository_id_repositories_id_fk": { + "name": "pull_requests_repository_id_repositories_id_fk", + "tableFrom": "pull_requests", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_requests_branch_id_branches_id_fk": { + "name": "pull_requests_branch_id_branches_id_fk", + "tableFrom": "pull_requests", + "tableTo": "branches", + "columnsFrom": [ + "branch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_subscriptions_user_id_idx": { + "name": "push_subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscriptions_user_endpoint_idx": { + "name": "push_subscriptions_user_endpoint_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_github_id_idx": { + "name": "repositories_github_id_idx", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_installation_id_idx": { + "name": "repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_full_name_idx": { + "name": "repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_installation_id_github_app_installations_id_fk": { + "name": "repositories_installation_id_github_app_installations_id_fk", + "tableFrom": "repositories", + "tableTo": "github_app_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repositories_github_id_unique": { + "name": "repositories_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository_settings": { + "name": "repository_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pruning_days": { + "name": "pruning_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 14 + }, + "ignored_paths": { + "name": "ignored_paths", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "notify_on_new_overlap": { + "name": "notify_on_new_overlap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_severity_increase": { + "name": "notify_on_severity_increase", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "repository_settings_repository_id_repositories_id_fk": { + "name": "repository_settings_repository_id_repositories_id_fk", + "tableFrom": "repository_settings", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repository_settings_repository_id_unique": { + "name": "repository_settings_repository_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_installations": { + "name": "user_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_installations_user_installation_idx": { + "name": "user_installations_user_installation_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_installations_user_id_idx": { + "name": "user_installations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_installations_installation_id_idx": { + "name": "user_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_installations_user_id_users_id_fk": { + "name": "user_installations_user_id_users_id_fk", + "tableFrom": "user_installations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_installations_installation_id_github_app_installations_id_fk": { + "name": "user_installations_installation_id_github_app_installations_id_fk", + "tableFrom": "user_installations", + "tableTo": "github_app_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_github_id_idx": { + "name": "users_github_id_idx", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_events": { + "name": "webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_delivery_id_idx": { + "name": "webhook_events_delivery_id_idx", + "columns": [ + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_event_type_idx": { + "name": "webhook_events_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_created_at_idx": { + "name": "webhook_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_events_repository_id_repositories_id_fk": { + "name": "webhook_events_repository_id_repositories_id_fk", + "tableFrom": "webhook_events", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_events_delivery_id_unique": { + "name": "webhook_events_delivery_id_unique", + "nullsNotDistinct": false, + "columns": [ + "delivery_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0004_snapshot.json b/packages/db/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..07f7562 --- /dev/null +++ b/packages/db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1726 @@ +{ + "id": "6d54c1f3-8da8-4d42-a20f-3421b84dceff", + "prevId": "296a25d0-ccf1-488f-a99b-4248bcef0bd7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.branch_files": { + "name": "branch_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "branch_id": { + "name": "branch_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_type": { + "name": "change_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_files_branch_path_idx": { + "name": "branch_files_branch_path_idx", + "columns": [ + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_files_branch_id_idx": { + "name": "branch_files_branch_id_idx", + "columns": [ + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_files_file_path_idx": { + "name": "branch_files_file_path_idx", + "columns": [ + { + "expression": "file_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_files_branch_id_branches_id_fk": { + "name": "branch_files_branch_id_branches_id_fk", + "tableFrom": "branch_files", + "tableTo": "branches", + "columnsFrom": [ + "branch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.branches": { + "name": "branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sha": { + "name": "sha", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_pusher_github_id": { + "name": "last_pusher_github_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branches_repo_name_idx": { + "name": "branches_repo_name_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branches_repository_id_idx": { + "name": "branches_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branches_last_seen_at_idx": { + "name": "branches_last_seen_at_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branches_repository_id_repositories_id_fk": { + "name": "branches_repository_id_repositories_id_fk", + "tableFrom": "branches", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_app_installations": { + "name": "github_app_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "installations_installation_id_idx": { + "name": "installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_app_installations_organization_id_organizations_id_fk": { + "name": "github_app_installations_organization_id_organizations_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_app_installations_user_id_users_id_fk": { + "name": "github_app_installations_user_id_users_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_app_installations_installation_id_unique": { + "name": "github_app_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_github_id_idx": { + "name": "organizations_github_id_idx", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_github_id_unique": { + "name": "organizations_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.overlap_files": { + "name": "overlap_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "overlap_id": { + "name": "overlap_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_change_type": { + "name": "source_change_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "target_change_type": { + "name": "target_change_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "overlap_files_overlap_path_idx": { + "name": "overlap_files_overlap_path_idx", + "columns": [ + { + "expression": "overlap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "overlap_files_overlap_id_idx": { + "name": "overlap_files_overlap_id_idx", + "columns": [ + { + "expression": "overlap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "overlap_files_overlap_id_overlaps_id_fk": { + "name": "overlap_files_overlap_id_overlaps_id_fk", + "tableFrom": "overlap_files", + "tableTo": "overlaps", + "columnsFrom": [ + "overlap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.overlaps": { + "name": "overlaps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_branch_id": { + "name": "source_branch_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_branch_id": { + "name": "target_branch_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'low'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "overlaps_branches_idx": { + "name": "overlaps_branches_idx", + "columns": [ + { + "expression": "source_branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "overlaps_repository_id_idx": { + "name": "overlaps_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "overlaps_status_idx": { + "name": "overlaps_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "overlaps_severity_idx": { + "name": "overlaps_severity_idx", + "columns": [ + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "overlaps_repository_id_repositories_id_fk": { + "name": "overlaps_repository_id_repositories_id_fk", + "tableFrom": "overlaps", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "overlaps_source_branch_id_branches_id_fk": { + "name": "overlaps_source_branch_id_branches_id_fk", + "tableFrom": "overlaps", + "tableTo": "branches", + "columnsFrom": [ + "source_branch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "overlaps_target_branch_id_branches_id_fk": { + "name": "overlaps_target_branch_id_branches_id_fk", + "tableFrom": "overlaps", + "tableTo": "branches", + "columnsFrom": [ + "target_branch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_alerts": { + "name": "pr_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "overlap_id": { + "name": "overlap_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "alert_type": { + "name": "alert_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "check_run_id": { + "name": "check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_alerts_pr_overlap_idx": { + "name": "pr_alerts_pr_overlap_idx", + "columns": [ + { + "expression": "pull_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "overlap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_alerts_pull_request_id_idx": { + "name": "pr_alerts_pull_request_id_idx", + "columns": [ + { + "expression": "pull_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_alerts_overlap_id_idx": { + "name": "pr_alerts_overlap_id_idx", + "columns": [ + { + "expression": "overlap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_alerts_pull_request_id_pull_requests_id_fk": { + "name": "pr_alerts_pull_request_id_pull_requests_id_fk", + "tableFrom": "pr_alerts", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_alerts_overlap_id_overlaps_id_fk": { + "name": "pr_alerts_overlap_id_overlaps_id_fk", + "tableFrom": "pr_alerts", + "tableTo": "overlaps", + "columnsFrom": [ + "overlap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pull_requests": { + "name": "pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "github_pr_number": { + "name": "github_pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_requests_repo_number_idx": { + "name": "pull_requests_repo_number_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_requests_repository_id_idx": { + "name": "pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_requests_branch_id_idx": { + "name": "pull_requests_branch_id_idx", + "columns": [ + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_requests_state_idx": { + "name": "pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_requests_repository_id_repositories_id_fk": { + "name": "pull_requests_repository_id_repositories_id_fk", + "tableFrom": "pull_requests", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_requests_branch_id_branches_id_fk": { + "name": "pull_requests_branch_id_branches_id_fk", + "tableFrom": "pull_requests", + "tableTo": "branches", + "columnsFrom": [ + "branch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_subscriptions_user_id_idx": { + "name": "push_subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscriptions_user_endpoint_idx": { + "name": "push_subscriptions_user_endpoint_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_github_id_idx": { + "name": "repositories_github_id_idx", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_installation_id_idx": { + "name": "repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_full_name_idx": { + "name": "repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_installation_id_github_app_installations_id_fk": { + "name": "repositories_installation_id_github_app_installations_id_fk", + "tableFrom": "repositories", + "tableTo": "github_app_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repositories_github_id_unique": { + "name": "repositories_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository_settings": { + "name": "repository_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pruning_days": { + "name": "pruning_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 14 + }, + "ignored_paths": { + "name": "ignored_paths", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "notify_on_new_overlap": { + "name": "notify_on_new_overlap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_severity_increase": { + "name": "notify_on_severity_increase", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "repository_settings_repository_id_repositories_id_fk": { + "name": "repository_settings_repository_id_repositories_id_fk", + "tableFrom": "repository_settings", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repository_settings_repository_id_unique": { + "name": "repository_settings_repository_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_installations": { + "name": "user_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_installations_user_installation_idx": { + "name": "user_installations_user_installation_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_installations_user_id_idx": { + "name": "user_installations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_installations_installation_id_idx": { + "name": "user_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_installations_user_id_users_id_fk": { + "name": "user_installations_user_id_users_id_fk", + "tableFrom": "user_installations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_installations_installation_id_github_app_installations_id_fk": { + "name": "user_installations_installation_id_github_app_installations_id_fk", + "tableFrom": "user_installations", + "tableTo": "github_app_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_github_id_idx": { + "name": "users_github_id_idx", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_events": { + "name": "webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_delivery_id_idx": { + "name": "webhook_events_delivery_id_idx", + "columns": [ + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_event_type_idx": { + "name": "webhook_events_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_created_at_idx": { + "name": "webhook_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_events_repository_id_repositories_id_fk": { + "name": "webhook_events_repository_id_repositories_id_fk", + "tableFrom": "webhook_events", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_events_delivery_id_unique": { + "name": "webhook_events_delivery_id_unique", + "nullsNotDistinct": false, + "columns": [ + "delivery_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 92a7322..eab9b1c 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1770340000000, "tag": "0003_user_installations", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1786589456876, + "tag": "0004_noisy_daimon_hellstrom", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index dfa8206..c41ce53 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -8,11 +8,48 @@ if (!connectionString) { throw new Error('DATABASE_URL environment variable is not set') } -// For query purposes -const queryClient = postgres(connectionString) +// DATABASE_URL must be the POOLED connection string. +// +// On Neon, pooled and direct differ by HOSTNAME, not by port: the pooled +// endpoint carries a `-pooler` suffix (`ep-xxx-pooler.region.aws.neon.tech`) +// while the direct one does not. Both use 5432. This is worth stating because +// the more common Supabase shape distinguishes them by port (6543 vs 5432), +// and a connection string that looks right can silently be the wrong one. +// +// `prepare: false` is set because the pooler runs PgBouncer in transaction +// mode. Neon's PgBouncer does support protocol-level prepared statements, so +// this is conservative rather than strictly required; it is kept because it +// is known-safe under transaction pooling and the performance difference is +// immaterial at this workload's query volume. Do not remove it without +// testing under real concurrency, since the failure mode is intermittent +// `prepared statement "s1" already exists` rather than a clean error. +// +// `max` and `idle_timeout` bound what each warm serverless instance holds. +// The postgres-js defaults (`max: 10`, no `idle_timeout`) mean an instance +// keeps up to 10 pooler connections open for its entire lifetime, even while +// idle. Neon's pooler tolerates far more client connections than a Supavisor +// tier would (thousands rather than hundreds), so exhaustion is unlikely +// here - but an unbounded idle pool is wasteful regardless, and these values +// keep the app portable to a pooler with a tighter budget. +// +// - `max: 3` sits above anything a single request does concurrently. +// - `idle_timeout: 10` (seconds) returns connections after a burst instead +// of holding them for the life of the instance. +const queryClient = postgres(connectionString, { + prepare: false, + max: 3, + idle_timeout: 10, +}) -// For migrations -export const migrationClient = postgres(connectionString, { max: 1 }) +// Migrations need a direct (unpooled) connection: they run DDL and rely on +// session state, which transaction pooling does not preserve. On Neon that +// means the endpoint WITHOUT the `-pooler` suffix. +const directConnectionString = process.env.DIRECT_URL || connectionString + +export const migrationClient = postgres(directConnectionString, { + max: 1, + prepare: false, +}) export const db = drizzle(queryClient, { schema }) diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 64bcb89..8f176c3 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -288,6 +288,7 @@ export const webhookEvents = pgTable( onDelete: 'set null', }), payload: jsonb('payload').$type>().notNull(), + dispatchedAt: timestamp('dispatched_at', { withTimezone: true }), processedAt: timestamp('processed_at', { withTimezone: true }), error: text('error'), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), diff --git a/packages/github/src/client.ts b/packages/github/src/client.ts index fac37d7..211f697 100644 --- a/packages/github/src/client.ts +++ b/packages/github/src/client.ts @@ -1,105 +1,6 @@ import { Octokit } from '@octokit/rest' import { createAppAuth } from '@octokit/auth-app' -// Rate limit handling -export class RateLimitError extends Error { - retryAfter: number - - constructor(message: string, retryAfter: number) { - super(message) - this.name = 'RateLimitError' - this.retryAfter = retryAfter - } -} - -async function withRetry( - fn: () => Promise, - maxRetries = 3, - baseDelay = 1000 -): Promise { - let lastError: Error | null = null - - for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - return await fn() - } catch (error: unknown) { - lastError = error as Error - - // Check for rate limit - if (isRateLimitError(error)) { - const retryAfter = getRateLimitRetryAfter(error) - if (retryAfter > 0 && retryAfter < 300) { - // Max wait 5 minutes - console.log(`Rate limited. Waiting ${retryAfter}s before retry...`) - await sleep(retryAfter * 1000) - continue - } - throw new RateLimitError('GitHub API rate limit exceeded', retryAfter) - } - - // Check for secondary rate limit (abuse detection) - if (isSecondaryRateLimit(error)) { - const delay = baseDelay * Math.pow(2, attempt) - console.log(`Secondary rate limit. Waiting ${delay}ms before retry...`) - await sleep(delay) - continue - } - - // Check for transient errors (5xx) - if (isTransientError(error) && attempt < maxRetries - 1) { - const delay = baseDelay * Math.pow(2, attempt) - console.log(`Transient error. Waiting ${delay}ms before retry...`) - await sleep(delay) - continue - } - - throw error - } - } - - throw lastError -} - -function isRateLimitError(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false - const err = error as { status?: number; response?: { headers?: Record } } - return err.status === 403 && err.response?.headers?.['x-ratelimit-remaining'] === '0' -} - -function isSecondaryRateLimit(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false - const err = error as { status?: number; message?: string } - return err.status === 403 && (err.message?.includes('secondary rate limit') ?? false) -} - -function isTransientError(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false - const err = error as { status?: number } - return typeof err.status === 'number' && err.status >= 500 -} - -function getRateLimitRetryAfter(error: unknown): number { - if (typeof error !== 'object' || error === null) return 60 - const err = error as { response?: { headers?: Record } } - const retryAfter = err.response?.headers?.['retry-after'] - const resetTime = err.response?.headers?.['x-ratelimit-reset'] - - if (retryAfter) { - return parseInt(retryAfter, 10) - } - - if (resetTime) { - const resetTimestamp = parseInt(resetTime, 10) * 1000 - return Math.max(0, Math.ceil((resetTimestamp - Date.now()) / 1000)) - } - - return 60 // Default to 60 seconds -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - export interface GitHubConfig { appId: string privateKey: string @@ -184,19 +85,17 @@ export class GitHubClient { ): Promise { const octokit = await this.getInstallationClient(installationId) - return withRetry(async () => { - const { data } = await octokit.repos.getCommit({ - owner, - repo, - ref: sha, - }) - - return (data.files || []).map((file) => ({ - filename: file.filename, - status: file.status as CommitFile['status'], - previousFilename: file.previous_filename, - })) + const { data } = await octokit.repos.getCommit({ + owner, + repo, + ref: sha, }) + + return (data.files || []).map((file) => ({ + filename: file.filename, + status: file.status as CommitFile['status'], + previousFilename: file.previous_filename, + })) } /** @@ -211,20 +110,18 @@ export class GitHubClient { ): Promise { const octokit = await this.getInstallationClient(installationId) - return withRetry(async () => { - const { data } = await octokit.repos.compareCommits({ - owner, - repo, - base, - head, - }) - - return (data.files || []).map((file) => ({ - filename: file.filename, - status: file.status as CommitFile['status'], - previousFilename: file.previous_filename, - })) + const { data } = await octokit.repos.compareCommits({ + owner, + repo, + base, + head, }) + + return (data.files || []).map((file) => ({ + filename: file.filename, + status: file.status as CommitFile['status'], + previousFilename: file.previous_filename, + })) } /** @@ -239,24 +136,22 @@ export class GitHubClient { ): Promise { const octokit = await this.getInstallationClient(installationId) - return withRetry(async () => { - const { data } = await octokit.repos.compareCommits({ - owner, - repo, - base, - head, - }) - - return (data.files || []).map((file) => ({ - filename: file.filename, - status: file.status ?? 'modified', - additions: file.additions, - deletions: file.deletions, - changes: file.changes, - patch: file.patch ?? null, - previousFilename: file.previous_filename, - })) + const { data } = await octokit.repos.compareCommits({ + owner, + repo, + base, + head, }) + + return (data.files || []).map((file) => ({ + filename: file.filename, + status: file.status ?? 'modified', + additions: file.additions, + deletions: file.deletions, + changes: file.changes, + patch: file.patch ?? null, + previousFilename: file.previous_filename, + })) } /** @@ -317,26 +212,24 @@ export class GitHubClient { ): Promise { const octokit = await this.getInstallationClient(installationId) - return withRetry(async () => { - if (existingCommentId) { - await octokit.issues.updateComment({ - owner, - repo, - comment_id: existingCommentId, - body, - }) - return existingCommentId - } - - const { data } = await octokit.issues.createComment({ + if (existingCommentId) { + await octokit.issues.updateComment({ owner, repo, - issue_number: prNumber, + comment_id: existingCommentId, body, }) + return existingCommentId + } - return data.id + const { data } = await octokit.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, }) + + return data.id } /** @@ -354,22 +247,49 @@ export class GitHubClient { ): Promise { const octokit = await this.getInstallationClient(installationId) - return withRetry(async () => { - const { data } = await octokit.checks.create({ - owner, - repo, - name, - head_sha: headSha, - status: 'completed', - conclusion, - output: { - title, - summary, - }, - }) + const { data } = await octokit.checks.create({ + owner, + repo, + name, + head_sha: headSha, + status: 'completed', + conclusion, + output: { + title, + summary, + }, + }) + + return data.id + } + + /** + * Update an existing check run + */ + async updateCheckRun( + installationId: number, + owner: string, + repo: string, + checkRunId: number, + conclusion: 'success' | 'failure' | 'neutral', + title: string, + summary: string + ): Promise { + const octokit = await this.getInstallationClient(installationId) - return data.id + const { data } = await octokit.checks.update({ + owner, + repo, + check_run_id: checkRunId, + status: 'completed', + conclusion, + output: { + title, + summary, + }, }) + + return data.id } /** diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts index 3200999..602ca0b 100644 --- a/packages/github/src/index.ts +++ b/packages/github/src/index.ts @@ -1,7 +1,6 @@ export { GitHubClient, getGitHubClient, - RateLimitError, type GitHubConfig, type CommitFile, type FileDiff, diff --git a/packages/github/src/webhooks.ts b/packages/github/src/webhooks.ts index 2be96d1..a28be5e 100644 --- a/packages/github/src/webhooks.ts +++ b/packages/github/src/webhooks.ts @@ -116,7 +116,7 @@ export function formatOverlapComment( } comment += `---\n` - comment += `*Overlap detected by [Overlap](https://overlap.dev) — coordinate early to avoid merge conflicts.*` + comment += `*Overlap detected by [Overlap](https://overlap.dev) - coordinate early to avoid merge conflicts.*` return comment } diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 7636103..2d03490 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -1,31 +1,3 @@ -// ============================================================================ -// Queue Names -// ============================================================================ - -export const QUEUE_NAMES = { - WEBHOOK_EVENTS: 'webhook-events', - BRANCH_SYNC: 'branch-sync', - OVERLAP_DETECTION: 'overlap-detection', - GITHUB_FEEDBACK: 'github-feedback', - MAINTENANCE: 'maintenance', - PUSH_NOTIFICATION: 'push-notification', -} as const - -export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES] - -// ============================================================================ -// Rate Limits -// ============================================================================ - -export const RATE_LIMITS = { - [QUEUE_NAMES.WEBHOOK_EVENTS]: { max: 100, duration: 1000 }, // 100/sec - [QUEUE_NAMES.BRANCH_SYNC]: { max: 30, duration: 60000 }, // 30/min - [QUEUE_NAMES.OVERLAP_DETECTION]: { max: 50, duration: 1000 }, // 50/sec - [QUEUE_NAMES.GITHUB_FEEDBACK]: { max: 10, duration: 60000 }, // 10/min - [QUEUE_NAMES.MAINTENANCE]: { max: 5, duration: 60000 }, // 5/min - [QUEUE_NAMES.PUSH_NOTIFICATION]: { max: 20, duration: 1000 }, // 20/sec -} as const - // ============================================================================ // Default Settings // ============================================================================ @@ -84,23 +56,6 @@ export const PR_ACTIONS = { CLOSED: 'closed', } as const -// ============================================================================ -// API Endpoints -// ============================================================================ - -export const API_ROUTES = { - AUTH: { - GITHUB: '/auth/github', - CALLBACK: '/auth/github/callback', - LOGOUT: '/auth/logout', - ME: '/auth/me', - }, - REPOSITORIES: '/repositories', - WEBHOOKS: { - GITHUB: '/webhooks/github', - }, -} as const - // ============================================================================ // Error Codes // ============================================================================ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c89f267..9ab43cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,15 @@ importers: .: devDependencies: + '@electric-sql/pglite': + specifier: 0.2.17 + version: 0.2.17 '@types/node': specifier: ^20.11.0 version: 20.19.31 eslint: specifier: ^9.0.0 - version: 9.39.2(jiti@2.6.1) + version: 9.39.2(jiti@2.7.0) prettier: specifier: ^3.2.0 version: 3.8.1 @@ -23,61 +26,18 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + typescript-eslint: + specifier: ^8.67.0 + version: 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) - apps/api: + apps/web: dependencies: - '@fastify/cookie': - specifier: ^11.0.0 - version: 11.0.2 - '@fastify/cors': - specifier: ^10.0.0 - version: 10.1.0 - '@fastify/rate-limit': - specifier: ^10.0.0 - version: 10.3.0 '@overlap/db': - specifier: workspace:* + specifier: workspace:^ version: link:../../packages/db '@overlap/github': specifier: workspace:* version: link:../../packages/github - '@overlap/shared': - specifier: workspace:* - version: link:../../packages/shared - bullmq: - specifier: ^5.25.0 - version: 5.67.2 - drizzle-orm: - specifier: ^0.38.0 - version: 0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) - fastify: - specifier: ^5.0.0 - version: 5.7.4 - fastify-plugin: - specifier: ^5.1.0 - version: 5.1.0 - ioredis: - specifier: ^5.4.0 - version: 5.9.2 - devDependencies: - '@types/node': - specifier: ^20.11.0 - version: 20.19.31 - pino-pretty: - specifier: ^13.0.0 - version: 13.1.3 - tsup: - specifier: ^8.0.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) - tsx: - specifier: ^4.19.0 - version: 4.21.0 - typescript: - specifier: ^5.7.0 - version: 5.9.3 - - apps/web: - dependencies: '@overlap/shared': specifier: workspace:* version: link:../../packages/shared @@ -113,19 +73,28 @@ importers: version: 1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start': specifier: ^1.158.0 - version: 1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) class-variance-authority: specifier: ^0.7.0 version: 0.7.1 clsx: specifier: ^2.1.0 version: 2.1.1 + drizzle-orm: + specifier: ^0.38.0 + version: 0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) + jose: + specifier: ^6.2.8 + version: 6.2.8 lucide-react: specifier: ^0.470.0 version: 0.470.0(react@19.2.4) + minimatch: + specifier: ^10.0.0 + version: 10.2.6 nitro: specifier: 3.0.1-alpha.2 - version: 3.0.1-alpha.2(chokidar@4.0.3)(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 3.0.1-alpha.2(@electric-sql/pglite@0.2.17)(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) react: specifier: ^19.0.0 version: 19.2.4 @@ -135,10 +104,16 @@ importers: tailwind-merge: specifier: ^2.6.0 version: 2.6.1 + web-push: + specifier: ^3.6.7 + version: 3.6.7 + workflow: + specifier: ^4.8.2 + version: 4.8.2(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)(typescript@5.9.3) devDependencies: '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.1.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 4.1.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) '@types/node': specifier: ^20.11.0 version: 20.19.31 @@ -148,9 +123,18 @@ importers: '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.10) + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 '@vitejs/plugin-react': specifier: ^5.1.0 - version: 5.1.3(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 5.1.3(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) + '@workflow/vitest': + specifier: 4.0.18 + version: 4.0.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.1.10(@types/node@20.19.31)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))) + esbuild: + specifier: ^0.28.2 + version: 0.28.2 tailwindcss: specifier: ^4.0.0 version: 4.1.18 @@ -159,53 +143,13 @@ importers: version: 5.9.3 vite: specifier: ^7.3.0 - version: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) vite-tsconfig-paths: specifier: ^5.1.0 - version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) - - apps/worker: - dependencies: - '@overlap/db': - specifier: workspace:* - version: link:../../packages/db - '@overlap/github': - specifier: workspace:* - version: link:../../packages/github - '@overlap/shared': - specifier: workspace:* - version: link:../../packages/shared - bullmq: - specifier: ^5.25.0 - version: 5.67.2 - drizzle-orm: - specifier: ^0.38.0 - version: 0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) - ioredis: - specifier: ^5.4.0 - version: 5.9.2 - minimatch: - specifier: ^10.0.0 - version: 10.1.2 - web-push: - specifier: ^3.6.7 - version: 3.6.7 - devDependencies: - '@types/node': - specifier: ^20.11.0 - version: 20.19.31 - '@types/web-push': - specifier: ^3.6.4 - version: 3.6.4 - tsup: - specifier: ^8.0.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) - tsx: - specifier: ^4.19.0 - version: 4.21.0 - typescript: - specifier: ^5.7.0 - version: 5.9.3 + version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@20.19.31)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) packages/db: dependencies: @@ -214,7 +158,7 @@ importers: version: link:../shared drizzle-orm: specifier: ^0.38.0 - version: 0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) + version: 0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) postgres: specifier: ^3.4.0 version: 3.4.8 @@ -260,6 +204,34 @@ importers: packages: + '@aws-sdk/core@3.977.7': + resolution: {integrity: sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.49': + resolution: {integrity: sha512-IYx1lN38MnnPXv+NBLpuATu0cZakbZ321TAfjW+aVkw7HIJF38YnEwdeEO55MSl3pl7hIX1IvvnD6EmnAzmAJw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.42': + resolution: {integrity: sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.44': + resolution: {integrity: sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.3': + resolution: {integrity: sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.38': + resolution: {integrity: sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -359,9 +331,45 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} + cpu: [x64] + os: [win32] + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@electric-sql/pglite@0.2.17': + resolution: {integrity: sha512-qEpKRT2oUaWDH6tjRxLHjdzMqRUGYDnGZlKrnL4dJ77JVMcP2Hpo3NYnOSPKdZdeec57B6QPprCUFg0picx5Pw==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -391,6 +399,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.18.20': resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} @@ -409,6 +423,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.18.20': resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} @@ -427,6 +447,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.18.20': resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} engines: {node: '>=12'} @@ -445,6 +471,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.18.20': resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} engines: {node: '>=12'} @@ -463,6 +495,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.18.20': resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} engines: {node: '>=12'} @@ -481,6 +519,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} engines: {node: '>=12'} @@ -499,6 +543,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} engines: {node: '>=12'} @@ -517,6 +567,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.18.20': resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} engines: {node: '>=12'} @@ -535,6 +591,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.18.20': resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} engines: {node: '>=12'} @@ -553,6 +615,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.18.20': resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} engines: {node: '>=12'} @@ -571,6 +639,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.18.20': resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} @@ -589,6 +663,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.18.20': resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} engines: {node: '>=12'} @@ -607,6 +687,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.18.20': resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} engines: {node: '>=12'} @@ -625,6 +711,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.18.20': resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} engines: {node: '>=12'} @@ -643,6 +735,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.18.20': resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} engines: {node: '>=12'} @@ -661,6 +759,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.18.20': resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} engines: {node: '>=12'} @@ -679,12 +783,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.2': resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} engines: {node: '>=12'} @@ -703,12 +819,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.2': resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} engines: {node: '>=12'} @@ -727,12 +855,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.2': resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} @@ -751,6 +891,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.18.20': resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} engines: {node: '>=12'} @@ -769,6 +915,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.18.20': resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} engines: {node: '>=12'} @@ -787,6 +939,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.18.20': resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} engines: {node: '>=12'} @@ -805,6 +963,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -843,33 +1007,6 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@fastify/ajv-compiler@4.0.5': - resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} - - '@fastify/cookie@11.0.2': - resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} - - '@fastify/cors@10.1.0': - resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==} - - '@fastify/error@4.2.0': - resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} - - '@fastify/fast-json-stringify-compiler@5.0.3': - resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} - - '@fastify/forwarded@3.0.1': - resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} - - '@fastify/merge-json-schemas@0.2.1': - resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} - - '@fastify/proxy-addr@5.1.0': - resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - - '@fastify/rate-limit@10.3.0': - resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} - '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -904,14 +1041,6 @@ packages: '@ioredis/commands@1.5.0': resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.1': - resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} - engines: {node: 20 || >=22} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -928,54 +1057,176 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lukeed/ms@2.0.2': - resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': - resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + '@napi-rs/nice-android-arm-eabi@1.1.1': + resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/nice-android-arm64@1.1.1': + resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/nice-darwin-arm64@1.1.1': + resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==} + engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': - resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + '@napi-rs/nice-darwin-x64@1.1.1': + resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==} + engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': - resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} - cpu: [arm64] - os: [linux] + '@napi-rs/nice-freebsd-x64@1.1.1': + resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': - resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==} + engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': - resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} - cpu: [x64] + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==} + engines: {node: '>= 10'} + cpu: [arm64] os: [linux] - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': - resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} - cpu: [x64] - os: [win32] + '@napi-rs/nice-linux-arm64-musl@1.1.1': + resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] - '@napi-rs/wasm-runtime@1.1.1': - resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] - '@octokit/auth-app@7.2.2': - resolution: {integrity: sha512-p6hJtEyQDCJEPN9ijjhEC/kpFHMHN4Gca9r+8S0S8EJi7NaWftaEmexjxxpT1DFBeJpN4u/5RE22ArnyypupJw==} - engines: {node: '>= 18'} + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] - '@octokit/auth-oauth-app@8.1.4': - resolution: {integrity: sha512-71iBa5SflSXcclk/OL3lJzdt4iFs56OJdpBGEBl1wULp7C58uiswZLV6TdRaiAzHP1LT8ezpbHlKuxADb+4NkQ==} - engines: {node: '>= 18'} + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] - '@octokit/auth-oauth-device@7.1.5': - resolution: {integrity: sha512-lR00+k7+N6xeECj0JuXeULQ2TSBB/zjTAmNF2+vyGPDEFx1dgk1hTDmL13MjbSmzusuAmuJD8Pu39rjp9jH6yw==} - engines: {node: '>= 18'} + '@napi-rs/nice-linux-x64-gnu@1.1.1': + resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/nice-linux-x64-musl@1.1.1': + resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/nice-openharmony-arm64@1.1.1': + resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [openharmony] + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/nice@1.1.1': + resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} + engines: {node: '>= 10'} + + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + + '@nestjs/common@11.1.29': + resolution: {integrity: sha512-zkeNRlfiQIH/044r5zphjNzKYxBRC4O00onTdCsH2qq0R30Ixo0gOS9so5TadEbJJCuGNm0Vx3PBE2FG2vkBdA==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/core@11.1.29': + resolution: {integrity: sha512-ANXnZxirMNAY+JuRHCIYIdTfbhX3rizeSbJloub/4EYwpIfCuUoaz9woOofWmywYYc3gsVkLxVZV03gqntI8vA==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nuxt/kit@4.4.8': + resolution: {integrity: sha512-ZUlZ5iYfyfJFDPluhn6ZxFWcsuxWbLnZBc8w3MAROcQ4lYfZ+qFpALBLSNlpc0zhOa++33EE+5PEbOAdVIY+dw==} + engines: {node: '>=18.12.0'} + + '@oclif/core@4.11.4': + resolution: {integrity: sha512-URwiQ5ALx/sJ2iH4vzXEd+H4K6NAI7LRs6Jag3hrgKEpGmaE6alfRC8qjO4GIgb6A3ACaJumqP9twi/M9ywdHQ==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-help@6.2.37': + resolution: {integrity: sha512-5N/X/FzlJaYfpaHwDC0YHzOzKDWa41s9t+4FpCDu4f9OMReds4JeNBaaWk9rlIzdKjh2M6AC5Q18ORfECRkHGA==} + engines: {node: '>=18.0.0'} + + '@octokit/auth-app@7.2.2': + resolution: {integrity: sha512-p6hJtEyQDCJEPN9ijjhEC/kpFHMHN4Gca9r+8S0S8EJi7NaWftaEmexjxxpT1DFBeJpN4u/5RE22ArnyypupJw==} + engines: {node: '>= 18'} + + '@octokit/auth-oauth-app@8.1.4': + resolution: {integrity: sha512-71iBa5SflSXcclk/OL3lJzdt4iFs56OJdpBGEBl1wULp7C58uiswZLV6TdRaiAzHP1LT8ezpbHlKuxADb+4NkQ==} + engines: {node: '>= 18'} + + '@octokit/auth-oauth-device@7.1.5': + resolution: {integrity: sha512-lR00+k7+N6xeECj0JuXeULQ2TSBB/zjTAmNF2+vyGPDEFx1dgk1hTDmL13MjbSmzusuAmuJD8Pu39rjp9jH6yw==} + engines: {node: '>= 18'} '@octokit/auth-oauth-user@5.1.6': resolution: {integrity: sha512-/R8vgeoulp7rJs+wfJ2LtXEVC7pjQTIqDab7wPKwVG6+2v/lUnCOub6vaHmysQBbb45FknM3tbHW8TOVqYHxCw==} @@ -1315,9 +1566,6 @@ packages: '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1858,6 +2106,129 @@ packages: cpu: [x64] os: [win32] + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@smithy/core@3.32.0': + resolution: {integrity: sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.7.0': + resolution: {integrity: sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.10.0': + resolution: {integrity: sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.0': + resolution: {integrity: sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.17.0': + resolution: {integrity: sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==} + engines: {node: '>=18.0.0'} + + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/cli@0.8.1': + resolution: {integrity: sha512-L+ACCGHCiS0VqHVep/INLVnvRvJ2XooQFLZq4L8snhxw1jsqz+XRcY313UsyPVturPPE1shW3jic7rt3qEQTSQ==} + engines: {node: '>= 20.19.0'} + hasBin: true + peerDependencies: + '@swc/core': ^1.2.66 + chokidar: ^5.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@swc/core-darwin-arm64@1.15.3': + resolution: {integrity: sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.3': + resolution: {integrity: sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.3': + resolution: {integrity: sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.3': + resolution: {integrity: sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.15.3': + resolution: {integrity: sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-x64-gnu@1.15.3': + resolution: {integrity: sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.15.3': + resolution: {integrity: sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.15.3': + resolution: {integrity: sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.3': + resolution: {integrity: sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.3': + resolution: {integrity: sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.3': + resolution: {integrity: sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -2057,6 +2428,13 @@ packages: resolution: {integrity: sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg==} engines: {node: '>=12'} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -2072,12 +2450,24 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.19.31': resolution: {integrity: sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A==} @@ -2092,162 +2482,575 @@ packages: '@types/web-push@3.6.4': resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} - '@vitejs/plugin-react@5.1.3': - resolution: {integrity: sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==} - engines: {node: ^20.19.0 || >=22.12.0} + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@typescript-eslint/parser': ^8.67.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - abstract-logging@2.0.1: - resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '>=4.8.4 <6.1.0' - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - ansis@4.2.0: - resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} - engines: {node: '>=14'} + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + '@vercel/cli-auth@0.0.1': + resolution: {integrity: sha512-CnqiuMlZ4pjs2LCPYiR6aLKPPd3Xb8SBI1Y7eotXKgpx6qgrGNY+E7EIyUt5ErGHJGIrCZyGG5WEo4bHtVmz2Q==} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} + '@vercel/cli-config@0.2.3': + resolution: {integrity: sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A==} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + '@vercel/cli-exec@1.0.1': + resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==} + engines: {node: '>= 18'} - aria-hidden@1.2.6: - resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} - engines: {node: '>=10'} + '@vercel/functions@3.9.3': + resolution: {integrity: sha512-cbzTdASCZDnufrABc8oO00e/FqlqFFSdld+iGZPhrWBDHP4Pu8ESKKYIzASqYvbYYUIWujBvEa5LLCcZzm7WEw==} + engines: {node: '>= 20'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + ws: '>=8' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true + ws: + optional: true - asn1.js@5.4.1: - resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} - ast-types@0.16.1: - resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} - engines: {node: '>=4'} + '@vercel/oidc@3.8.4': + resolution: {integrity: sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA==} + engines: {node: '>= 20'} - atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} + '@vercel/queue@0.3.1': + resolution: {integrity: sha512-6pjdXyNfdCQnj1nyeB1rPtll/XUhmciyeZJD0rpIUUwmcIfR+utDl6+iFvlHvsBdqAVT8UC6ydObT0v+xldfUQ==} + engines: {node: '>=20.0.0'} - avvio@9.1.0: - resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + '@vitejs/plugin-react@5.1.3': + resolution: {integrity: sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - babel-dead-code-elimination@1.0.12: - resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} - hasBin: true + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - before-after-hook@3.0.2: - resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - bn.js@4.12.2: - resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + '@workflow/astro@4.0.17': + resolution: {integrity: sha512-A0y+w4v/zp/B+/J9JTn6CIOESKCK9GnXI2W9PSZkUXep7tpaGgwg+b/SRhioBdXJZVoUP+KjhwFu3Gm7FZpRaw==} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} + '@workflow/builders@4.1.7': + resolution: {integrity: sha512-oSu7Bs/QsuLkdaLugOIrCCEwn5AaUz/Ps9VQKts+uliHU8GVQcSUwMHQzGN42mYgVztQw39K5A9Ayu0TdPYUAQ==} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + '@workflow/cli@4.3.6': + resolution: {integrity: sha512-p2Ktjo+bzR8D/KsSFPEdwd3fzzYTwdxPddXObxbYBOHvjYkro6mWNDEU3n5rofP4kxC3PzbtFjSBz/UPbu4Zfg==} hasBin: true - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + '@workflow/core@4.8.2': + resolution: {integrity: sha512-PAinyzMmNTfndLADQ00IuuyMtTxKpe3DvBrrFNOji7u4DYHfr44VIgRGRrMHp4ZaLPuO9+Hgnr1fF7wCpYcbVw==} + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + '@workflow/errors@4.2.1': + resolution: {integrity: sha512-gIe3vf2POUw2DV4Mi6BGiYGQdUZ4YMcWhDahVT9xI1W1NSsqifX73Hvk/68x/haPnDOr7/VnWN8/YAgeEVYE4A==} - bullmq@5.67.2: - resolution: {integrity: sha512-3KYqNqQptKcgksACO1li4YW9/jxEh6XWa1lUg4OFrHa80Pf0C7H9zeb6ssbQQDfQab/K3QCXopbZ40vrvcyrLw==} + '@workflow/nest@4.0.18': + resolution: {integrity: sha512-z2q4lXBvsFnF6vQ/1HMhAhokWeQZ6ZDZslUyrCy2aUnvG8573zcU6ftc27qztS5fqVKQmjMifQ4Y0FGKQV0tlw==} + hasBin: true + peerDependencies: + '@nestjs/common': '>=10.0.0' + '@nestjs/core': '>=10.0.0' + '@swc/cli': '>=0.4.0' + '@swc/core': '>=1.5.0' - bundle-require@5.1.0: - resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + '@workflow/next@4.1.6': + resolution: {integrity: sha512-rwJXXEtZyxObytSiRPAdYz9K2E9pJfvQEtjBtH74UvwuShb/JQuB/FvaXUdgf6dfO0URoV3GVaFGTKXZNuyShQ==} peerDependencies: - esbuild: '>=0.18' + next: '>13' + peerDependenciesMeta: + next: + optional: true - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} + '@workflow/nitro@4.1.8': + resolution: {integrity: sha512-OQbq51M6njaS1gdO5yVx+qqtYuFHwoA6as7bsWfXSCv9CCt+RCK7+NbiIJ781wT42Ld7KwIHR37Sfqen4LxgPA==} - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} + '@workflow/nuxt@4.0.18': + resolution: {integrity: sha512-MxskxH3UDmjmrOIJgzsC/W3jp39oxE2b+L9MpnZY5JvKmkODXFX7aWm1Dtj1jHWARU9k5VemNjvLfYTlfbFFFA==} - caniuse-lite@1.0.30001767: - resolution: {integrity: sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==} + '@workflow/rollup@4.0.17': + resolution: {integrity: sha512-+A9FjuuKo4+mebf7g2LOXu1MM2QTyAsQcvdJxa0Kx2Vd0BtXn6p1yjrhChWBSB/SsU0Whzu6Kn0nJfU5FfNcwQ==} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} + '@workflow/serde@4.1.2': + resolution: {integrity: sha512-KkkSUddEcaIvW7/QfVUk2PR93117HkDum45cXQEGkoxEmHOmqfOLJ4T1m4a1QABVC7O9TIqPxsBtDPgKIO+LOw==} - cheerio-select@2.1.0: - resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + '@workflow/sveltekit@4.0.17': + resolution: {integrity: sha512-quGpf7iC8oU3gg4mebFYhichCZAMo9c8swyYSoCfEpWHAcqhsWQ+VZeT26CYHioIbGxNxjtgKVYu9PCrOkxfKA==} - cheerio@1.2.0: - resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} - engines: {node: '>=20.18.1'} + '@workflow/swc-plugin@4.1.2': + resolution: {integrity: sha512-oSd+fSXtcrHMJ82OwEqyfhYpqr1EsSQY5IEpntkKSlzzkdTFV4hcAj0vlafgIfI3WfhqxrXUoHfp/S1XE49jcA==} + peerDependencies: + '@swc/core': 1.15.3 - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} + '@workflow/typescript-plugin@4.0.3': + resolution: {integrity: sha512-QJ7hmPHrrudgSsOFMML6AbHQpOzAThcQL9AKS06QWX96ZGba+bfsjq/czlyAVXtoluBfN4fw0pSfz/itUB7XVQ==} + peerDependencies: + typescript: '>=5.0.0' - chokidar@4.0.3: + '@workflow/utils@4.1.4': + resolution: {integrity: sha512-2zGTa0vCJJczErE8oI7JPKpGmXNbhgnGW7JWcz3qTTs5TKibtaAxha5DLq3WTTbwsgvuuxcnvtWqQ5hFxhWATg==} + + '@workflow/vite@4.0.17': + resolution: {integrity: sha512-+cBjYEmsirg+aIPY7e0tMEo44q9zwnX8DYt6vo42oplwT12CJp2OMzdfm1iEk/V0I0X2BIDWVuDcE0k7gnOrKw==} + + '@workflow/vitest@4.0.18': + resolution: {integrity: sha512-/CMpSI7YOwVayyzLjxkDCbmFTg8gPK/+YrsXcAJiY9jBrTgLU7VR3tABBJP6Kycz0Qy867NEOmbcWHnxOa91TA==} + peerDependencies: + vite: '>=6.0.0' + vitest: '>=3.1.0' + peerDependenciesMeta: + vite: + optional: true + + '@workflow/web@4.1.18': + resolution: {integrity: sha512-I8sXlh8cRanzFTtaWFF0bfRvCsKctOIdRO9QqS/uvb3/P91GYmvCuSqP/TTZdGaDaikL+IMTYRD1vCBScQRe4A==} + + '@workflow/world-local@4.2.4': + resolution: {integrity: sha512-MCsoTTNyPp6cTV8z3wqniTaJUdwlcggMjKRMadU4jHjX5UOhEFrUE9EeOBnJY2eFUyEGW//Z/WpRFm2b982lcg==} + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@workflow/world-vercel@4.6.2': + resolution: {integrity: sha512-wkhYRgR8SOyYHNFt6Ns4MN9pWQFugDpV0GFIMcbzD9lkPx5rh7qgUWJNnHdNKVmmS4p25uWDBcLu8h6t42Yp3g==} + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@workflow/world@4.3.1': + resolution: {integrity: sha512-gT67yCzMsm6SS5+2ho+b6dSd8qknTlDkfHvKnuWWt9fwkvpRr0WLWFOPvzILj3IY/mptDj3pFNDS9a6RWxEqQQ==} + + '@xhmikosr/archive-type@8.1.0': + resolution: {integrity: sha512-EXOjEbnZFE5c/nFMf4FOrEURVanzHpnkPYmnmr78u02/8hAhE0FMq8p9TK1IM0/bFr5VcyBUY0gfLm8f7dKy+Q==} + engines: {node: '>=20'} + + '@xhmikosr/bin-check@8.2.2': + resolution: {integrity: sha512-Y/b0YJoCDda6DCFj8ikks06GrEWDsz/3vdgGLeectV9p+YJc76YugRjtqFdd2KTf2rnEPjalL2hcXP+x2KcSLQ==} + engines: {node: '>=20'} + + '@xhmikosr/bin-wrapper@14.5.1': + resolution: {integrity: sha512-UZUuTYWxeAbTIiRKKEAmV3csoE36B3CGFZrYYn87+bSEBTyJ32p5gx5Gmj5HOgyOtioFUypbOZ1V5M/l/VoePw==} + engines: {node: '>=20'} + + '@xhmikosr/decompress-tar@9.0.2': + resolution: {integrity: sha512-8nPZ6lZ3ExhsSxi/X/PMB3K+Vtsuxk43HowxYpxw4AsCHTYqFBXwC8B3Y+M/meaUOGOVm+2tFNUAfWGRjBt+Ww==} + engines: {node: '>=20'} + + '@xhmikosr/decompress-tarbz2@9.0.2': + resolution: {integrity: sha512-m0DvZhE7remCxtS8xY2iHSjivT4v+iyYDdfNoeuu8Nm+7g8xEXdLKSyDEicu4u1ImJLLGEfjMuTLera/F6UGWw==} + engines: {node: '>=20'} + + '@xhmikosr/decompress-targz@9.0.1': + resolution: {integrity: sha512-1JXu2b6yrpm5EuBoOzMU57B4qrHXJKWQQ7LlMynNEiz85mEjDciO3ayf//GXaTLLCEKiHjWlU3q3THjgf7uODA==} + engines: {node: '>=20'} + + '@xhmikosr/decompress-unzip@8.2.1': + resolution: {integrity: sha512-2MS94QnmXQwjkKN8WyFiu1sU7J3rcWJcMze4kRYsX7tN+CXpUGECgkh4YSOhujpkWPuVlFudIziJHO/TxOqkQQ==} + engines: {node: '>=20'} + + '@xhmikosr/decompress@11.1.4': + resolution: {integrity: sha512-ZbYL7SAfY37/TMpopqBR3mQiuQ76kI/RNpN4q82YHSw/UxktZNy8P4wgiKPSrTImMAWRaUG8UK1pgEa56YdLaw==} + engines: {node: '>=20'} + + '@xhmikosr/downloader@16.3.1': + resolution: {integrity: sha512-M67dvznaFbsvoqhGT4FsHWysaXXQ8386OViGZm0WOyQS3apW9p16WgvHp9nWj2vfKQAR2ZdqIBPNCHSM5rKSCg==} + engines: {node: '>=20'} + + '@xhmikosr/os-filter-obj@4.1.0': + resolution: {integrity: sha512-y5ArHvQ7BVule/+L9yE2nYMhceiJhgsqo58lOfnisQ7bg+Kjfmkgr7JBuVFiTkl+ErdShpp829QstZQyLugl8g==} + engines: {node: '>=20'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@3.17.0: + resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} + engines: {node: '>=14'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + async-listen@3.0.0: + resolution: {integrity: sha512-V+SsTpDqkrWTimiotsyl33ePSjA5/KrithwupuvJ6ztsqPvGv6ge4OredFhPffVXiLN/QUWvE0XcqJaYgt6fOg==} + engines: {node: '>= 14'} + + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + + before-after-hook@3.0.2: + resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + binary-version-check@6.1.0: + resolution: {integrity: sha512-REKdLKmuViV2WrtWXvNSiPX04KbIjfUV3Cy8batUeOg+FtmowavzJorfFhWq95cVJzINnL/44ixP26TrdJZACA==} + engines: {node: '>=18'} + + binary-version@7.1.0: + resolution: {integrity: sha512-Iy//vPc3ANPNlIWd242Npqc8MK0a/i4kVcHDlDA6HNMv5zMxz4ulIFhOSYJVKw/8AbHdHy0CnGYEt1QqSXxPsw==} + engines: {node: '>=18'} + + bn.js@4.12.2: + resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + boxen@8.0.1: + resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builtin-modules@5.0.0: + resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} + engines: {node: '>=18.20'} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + byte-counter@0.1.0: + resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==} + engines: {node: '>=20'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cacheable-lookup@7.0.0: + resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} + engines: {node: '>=14.16'} + + cacheable-request@13.0.19: + resolution: {integrity: sha512-SVXGH037+Mo1aIMO5B2UcleR43FGjFdN+M8JObSyEoQ2Mn4CODRWx28gN5jiTF0n5ItsgtIZfyargMNs8GX4kg==} + engines: {node: '>=18'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + + caniuse-lite@1.0.30001767: + resolution: {integrity: sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==} + + cbor-extract@2.2.2: + resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} + hasBin: true + + cbor-x@1.6.0: + resolution: {integrity: sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clean-stack@3.0.1: + resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} + engines: {node: '>=10'} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2263,36 +3066,60 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} engines: {node: '>= 6'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + convert-hrtime@5.0.0: + resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} + engines: {node: '>=12'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} - cron-parser@4.9.0: - resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} - engines: {node: '>=12.0.0'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} @@ -2316,8 +3143,8 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - dateformat@4.6.3: - resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} db0@0.3.4: resolution: {integrity: sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==} @@ -2351,16 +3178,45 @@ packages: supports-color: optional: true + decompress-response@10.0.0: + resolution: {integrity: sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==} + engines: {node: '>=20'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} @@ -2369,6 +3225,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -2386,6 +3245,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + drizzle-kit@0.30.6: resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==} hasBin: true @@ -2482,18 +3345,40 @@ packages: sqlite3: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + easy-table@1.2.0: + resolution: {integrity: sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.19.0: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} @@ -2514,6 +3399,28 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + errx@0.1.2: + resolution: {integrity: sha512-chfpPHmCerdo/rXr/nNvPZRkV4WwDRwzwnsJ0Uzz3tVi8Z41tDctRjduYy1138ii77AFlts1qvWtX3g/Acg91Q==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -2534,10 +3441,18 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2554,6 +3469,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2585,52 +3504,72 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.0.7: + resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} - fast-content-type-parse@2.0.1: - resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} + ext-list@2.2.2: + resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} + engines: {node: '>=0.10.0'} - fast-copy@4.0.2: - resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + ext-name@5.0.0: + resolution: {integrity: sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==} + engines: {node: '>=4'} - fast-decode-uri-component@1.0.1: - resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-content-type-parse@2.0.1: + resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-json-stringify@6.2.0: - resolution: {integrity: sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw==} - fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-querystring@1.1.2: - resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - - fastify-plugin@5.1.0: - resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - - fastify@5.7.4: - resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2640,24 +3579,48 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + filename-reserved-regex@4.0.0: + resolution: {integrity: sha512-9ZT504KxEQDamsOogZImAWGEN24R1uFAxU3ZS4AZqn2ooidmN68Olh7n4/RcA4lLatZztjA0ZSuxeLHVoCc8JA==} + engines: {node: '>=20'} + + filenamify@7.0.2: + resolution: {integrity: sha512-fz10TUqSZ1lG7ftW1KnRotJzMD8YRb6kaAQKpZJBLvqXXfFgIEpuazy1w2lK3zhMiBSdH/uF9LFlv5smJ2Jl1w==} + engines: {node: '>=20'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-my-way@9.4.0: - resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} - engines: {node: '>=20'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - fix-dts-default-cjs-exports@1.0.1: - resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + + find-versions@6.0.0: + resolution: {integrity: sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==} + engines: {node: '>=18'} flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} @@ -2666,11 +3629,34 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + form-data-encoder@4.1.0: + resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==} + engines: {node: '>= 18'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function-timeout@1.0.2: + resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} + engines: {node: '>=18'} + gel@2.2.0: resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==} engines: {node: '>= 18.0.0'} @@ -2680,13 +3666,45 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + 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-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@4.13.1: resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==} + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2695,6 +3713,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2702,6 +3723,14 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got@14.6.6: + resolution: {integrity: sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg==} + engines: {node: '>=20'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2718,12 +3747,32 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - help-me@5.0.0: - resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http2-wrapper@2.2.1: + resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} + engines: {node: '>=10.19.0'} + http_ece@1.2.0: resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} engines: {node: '>=16'} @@ -2732,14 +3781,37 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2748,33 +3820,102 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inspect-with-kind@1.0.5: + resolution: {integrity: sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g==} + ioredis@5.9.2: resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} engines: {node: '>=12.22.0'} - ipaddr.js@2.3.0: - resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} - engines: {node: '>= 10'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@1.1.0: + resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} + engines: {node: '>=0.10.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isbot@5.1.34: resolution: {integrity: sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A==} engines: {node: '>=18'} @@ -2786,13 +3927,32 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2809,15 +3969,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-schema-ref-resolver@3.0.0: - resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2826,22 +3980,36 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + + knitwork@1.3.0: + resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - light-my-request@6.6.0: - resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} - lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -2916,17 +4084,18 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -2936,6 +4105,14 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + lowercase-keys@3.0.0: + resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2944,53 +4121,107 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - luxon@3.7.2: - resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} - engines: {node: '>=12'} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-asynchronous@1.1.0: + resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} + engines: {node: '>=18'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mimic-response@4.0.0: + resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - minimatch@10.1.2: - resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} - engines: {node: 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mixpart@0.0.4: + resolution: {integrity: sha512-RAoaOSXnMLrfUfmFbNynRYjeMru/bhgAYRy/GQVI8gmRq7vm9V9c2gGVYnYoQ008X6YTmRIu5b0397U7vb0bIA==} + engines: {node: '>=22.0.0'} + + mixpart@0.0.6: + resolution: {integrity: sha512-CRdXtgfQH2jARmtNmPR0Q7jL20fiESbaYk1b0KvLD0jCdUuemepREtsbd8nbiY6BHV9OGGddAZITNXklupUPUQ==} + engines: {node: '>=20.0.0'} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - mnemonist@0.40.0: - resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msgpackr-extract@3.0.3: - resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} - hasBin: true - - msgpackr@1.11.5: - resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + nf3@0.3.7: resolution: {integrity: sha512-wL73kyZbBoeTWlvQWQ0gQDZnqp+aNlUN5YIqsc3fv5V/06LAlwrwt+G7TpugFLJIai0AhrmnKJ2kgW0xprj+yQ==} @@ -3013,11 +4244,8 @@ packages: xml2js: optional: true - node-abort-controller@3.1.1: - resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} - - node-gyp-build-optional-packages@5.2.2: - resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} hasBin: true node-releases@2.0.27: @@ -3027,15 +4255,32 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + normalize-url@8.1.1: + resolution: {integrity: sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==} + engines: {node: '>=14.16'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} - obliterator@2.0.5: - resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} ofetch@2.0.0-alpha.3: resolution: {integrity: sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==} @@ -3043,17 +4288,45 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} 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'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + open@8.4.0: + resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==} + engines: {node: '>=12'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + oxc-minify@0.110.0: resolution: {integrity: sha512-KWGTzPo83QmGrXC4ml83PM9HDwUPtZFfasiclUvTV4i3/0j7xRRqINVkrL77CbQnoWura3CMxkRofjQKVDuhBw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3062,18 +4335,42 @@ packages: resolution: {integrity: sha512-/fymQNzzUoKZweH0nC5yvbI2eR0yWYusT9TEKDYVgOgYrf9Qmdez9lUFyvxKR9ycx+PTHi/reIOzqf3wkShQsw==} engines: {node: ^20.19.0 || >=22.12.0} + p-cancelable@4.0.1: + resolution: {integrity: sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==} + engines: {node: '>=14.16'} + + p-event@6.0.1: + resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} + engines: {node: '>=16.17'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -3083,17 +4380,38 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3105,44 +4423,18 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pino-abstract-transport@3.0.0: - resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - - pino-pretty@13.1.3: - resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} - hasBin: true - - pino-std-serializers@7.1.0: - resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - - pino@10.3.0: - resolution: {integrity: sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==} - hasBin: true + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} + piscina@4.9.3: + resolution: {integrity: sha512-3e3ka9QCE8RJ5I9uszdAADZnkcYi21cqmF3gxox3u884N72qpFHCsIVhHt8cEQ9t3Auq/NqoiCEuhxlxxQuDWA==} pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} @@ -3161,21 +4453,36 @@ packages: engines: {node: '>=14'} hasBin: true - process-warning@4.0.1: - resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} - - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} @@ -3228,9 +4535,9 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} @@ -3244,31 +4551,26 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - ret@0.5.0: - resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} - engines: {node: '>=10'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + responselike@4.0.2: + resolution: {integrity: sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==} + engines: {node: '>=20'} - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} @@ -3278,15 +4580,19 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} - safe-regex2@5.0.0: - resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3294,8 +4600,23 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + seedrandom@3.0.5: + resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} + + seek-bzip@2.0.0: + resolution: {integrity: sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==} + hasBin: true + + semver-regex@4.0.5: + resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} + engines: {node: '>=12'} + + semver-truncate@3.0.0: + resolution: {integrity: sha512-LJWA9kSvMolR51oDE6PN3kALBNaUdkxzAGcexw8gjMA8xr5zUqK0JiR3CgARSqanYF3Z1YHvsErb1KDgh+v7Rg==} + engines: {node: '>=12'} semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} @@ -3306,6 +4627,20 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + seroval-plugins@1.5.0: resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} engines: {node: '>=10'} @@ -3316,8 +4651,12 @@ packages: resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} engines: {node: '>=10'} - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -3331,8 +4670,43 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} - sonic-boom@4.2.0: - resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + sort-keys-length@1.0.1: + resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==} + engines: {node: '>=0.10.0'} + + sort-keys@1.1.2: + resolution: {integrity: sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==} + engines: {node: '>=0.10.0'} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -3349,35 +4723,94 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - srvx@0.10.1: resolution: {integrity: sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg==} engines: {node: '>=20.16.0'} hasBin: true + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-dirs@3.0.0: + resolution: {integrity: sha512-I0sdgcFTfKQlUPZyAqPJmSG3HLO9rWDFnxonnIbskYNM3DwFOeTNB5KzVq3dA1GdRAc/25b5Y7UO2TQfKWw4aQ==} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strip-json-comments@5.0.3: - resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} - engines: {node: '>=14.16'} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true + super-regex@1.1.0: + resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} + engines: {node: '>=18'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@4.5.0: + resolution: {integrity: sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==} + engines: {node: '>=20'} + + system-architecture@1.0.0: + resolution: {integrity: sha512-0OJWD12D7XX3KUg1DYkMaTTjSTo2k/mhIYI3HlBlceXSMcJhW/1qO735fPKS5prcyjvn57Ub151vvASYXpQrEw==} + engines: {node: '>=18'} + tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} @@ -3388,30 +4821,48 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - thread-stream@4.0.0: - resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + terminal-link@5.0.0: + resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} engines: {node: '>=20'} + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + time-span@5.1.0: + resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} + engines: {node: '>=12'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3420,12 +4871,19 @@ packages: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} @@ -3440,25 +4898,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsup@8.5.1: - resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': - optional: true - '@swc/core': - optional: true - postcss: - optional: true - typescript: - optional: true - tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -3502,6 +4941,25 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3510,6 +4968,27 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + ulid@3.0.2: + resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==} + hasBin: true + + unbzip2-stream@1.4.3: + resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} + + unctx@2.5.0: + resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3517,15 +4996,35 @@ packages: resolution: {integrity: sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==} engines: {node: '>=20.18.1'} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + universal-github-app-jwt@2.2.2: resolution: {integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==} universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -3604,6 +5103,10 @@ packages: uploadthing: optional: true + untyped@2.0.0: + resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} + hasBin: true + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -3638,9 +5141,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} - hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} vite-tsconfig-paths@5.1.4: resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} @@ -3698,11 +5201,62 @@ packages: vite: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + watchpack@2.5.1: + resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-push@3.6.7: resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} engines: {node: '>= 16'} hasBin: true + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -3725,13 +5279,62 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + workflow@4.8.2: + resolution: {integrity: sha512-7WQOttNnODZLw3kYLEPwp8PD5ioxD72twSrAoulP6cDIFXOWEbXJWqDopwrNN9ChTBnrR0kRZk5VTGcVdeiDcg==} + hasBin: true + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xdg-app-paths@5.1.0: + resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} + engines: {node: '>=6'} + + 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'} + xmlbuilder2@4.0.3: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} @@ -3739,15 +5342,83 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: + '@aws-sdk/core@3.977.7': + dependencies: + '@aws-sdk/types': 3.974.3 + '@aws-sdk/xml-builder': 3.972.38 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.32.0 + '@smithy/signature-v4': 5.7.0 + '@smithy/types': 4.17.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.49': + dependencies: + '@aws-sdk/core': 3.977.7 + '@aws-sdk/nested-clients': 3.997.42 + '@aws-sdk/types': 3.974.3 + '@smithy/core': 3.32.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.42': + dependencies: + '@aws-sdk/core': 3.977.7 + '@aws-sdk/signature-v4-multi-region': 3.996.44 + '@aws-sdk/types': 3.974.3 + '@smithy/core': 3.32.0 + '@smithy/fetch-http-handler': 5.7.0 + '@smithy/node-http-handler': 4.10.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.44': + dependencies: + '@aws-sdk/types': 3.974.3 + '@smithy/signature-v4': 5.7.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.3': + dependencies: + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.38': + dependencies: + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3775,7 +5446,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -3867,7 +5538,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -3876,8 +5547,30 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@borewit/text-codec@0.2.2': {} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + optional: true + '@drizzle-team/brocli@0.10.2': {} + '@electric-sql/pglite@0.2.17': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -3910,6 +5603,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.18.20': optional: true @@ -3919,6 +5615,9 @@ snapshots: '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.18.20': optional: true @@ -3928,6 +5627,9 @@ snapshots: '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.18.20': optional: true @@ -3937,6 +5639,9 @@ snapshots: '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.18.20': optional: true @@ -3946,6 +5651,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.18.20': optional: true @@ -3955,6 +5663,9 @@ snapshots: '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.18.20': optional: true @@ -3964,6 +5675,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.18.20': optional: true @@ -3973,6 +5687,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.18.20': optional: true @@ -3982,6 +5699,9 @@ snapshots: '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.18.20': optional: true @@ -3991,6 +5711,9 @@ snapshots: '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.18.20': optional: true @@ -4000,6 +5723,9 @@ snapshots: '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.18.20': optional: true @@ -4009,6 +5735,9 @@ snapshots: '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.18.20': optional: true @@ -4018,6 +5747,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.18.20': optional: true @@ -4027,6 +5759,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.18.20': optional: true @@ -4036,6 +5771,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.18.20': optional: true @@ -4045,6 +5783,9 @@ snapshots: '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.18.20': optional: true @@ -4054,9 +5795,15 @@ snapshots: '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.18.20': optional: true @@ -4066,9 +5813,15 @@ snapshots: '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.18.20': optional: true @@ -4078,9 +5831,15 @@ snapshots: '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.18.20': optional: true @@ -4090,6 +5849,9 @@ snapshots: '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.18.20': optional: true @@ -4099,6 +5861,9 @@ snapshots: '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.18.20': optional: true @@ -4108,6 +5873,9 @@ snapshots: '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.18.20': optional: true @@ -4117,9 +5885,12 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.7.0))': dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -4127,7 +5898,7 @@ snapshots: '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -4143,7 +5914,7 @@ snapshots: '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -4163,45 +5934,6 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@fastify/ajv-compiler@4.0.5': - dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - fast-uri: 3.1.0 - - '@fastify/cookie@11.0.2': - dependencies: - cookie: 1.1.1 - fastify-plugin: 5.1.0 - - '@fastify/cors@10.1.0': - dependencies: - fastify-plugin: 5.1.0 - mnemonist: 0.40.0 - - '@fastify/error@4.2.0': {} - - '@fastify/fast-json-stringify-compiler@5.0.3': - dependencies: - fast-json-stringify: 6.2.0 - - '@fastify/forwarded@3.0.1': {} - - '@fastify/merge-json-schemas@0.2.1': - dependencies: - dequal: 2.0.3 - - '@fastify/proxy-addr@5.1.0': - dependencies: - '@fastify/forwarded': 3.0.1 - ipaddr.js: 2.3.0 - - '@fastify/rate-limit@10.3.0': - dependencies: - '@lukeed/ms': 2.0.2 - fastify-plugin: 5.1.0 - toad-cache: 3.7.0 - '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -4230,13 +5962,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@ioredis/commands@1.5.0': {} - - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.1': - dependencies: - '@isaacs/balanced-match': 4.0.1 + '@ioredis/commands@1.5.0': + optional: true '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -4257,24 +5984,80 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lukeed/ms@2.0.2': {} + '@keyv/serialize@1.1.1': {} + + '@lukeed/csprng@1.1.0': {} + + '@napi-rs/nice-android-arm-eabi@1.1.1': + optional: true + + '@napi-rs/nice-android-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-x64@1.1.1': + optional: true + + '@napi-rs/nice-freebsd-x64@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + optional: true + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + optional: true - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + '@napi-rs/nice-linux-x64-musl@1.1.1': optional: true - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + '@napi-rs/nice-openharmony-arm64@1.1.1': optional: true - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + '@napi-rs/nice-win32-arm64-msvc@1.1.1': optional: true - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + '@napi-rs/nice-win32-ia32-msvc@1.1.1': optional: true - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + '@napi-rs/nice-win32-x64-msvc@1.1.1': optional: true - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + '@napi-rs/nice@1.1.1': + optionalDependencies: + '@napi-rs/nice-android-arm-eabi': 1.1.1 + '@napi-rs/nice-android-arm64': 1.1.1 + '@napi-rs/nice-darwin-arm64': 1.1.1 + '@napi-rs/nice-darwin-x64': 1.1.1 + '@napi-rs/nice-freebsd-x64': 1.1.1 + '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1 + '@napi-rs/nice-linux-arm64-gnu': 1.1.1 + '@napi-rs/nice-linux-arm64-musl': 1.1.1 + '@napi-rs/nice-linux-ppc64-gnu': 1.1.1 + '@napi-rs/nice-linux-riscv64-gnu': 1.1.1 + '@napi-rs/nice-linux-s390x-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-musl': 1.1.1 + '@napi-rs/nice-openharmony-arm64': 1.1.1 + '@napi-rs/nice-win32-arm64-msvc': 1.1.1 + '@napi-rs/nice-win32-ia32-msvc': 1.1.1 + '@napi-rs/nice-win32-x64-msvc': 1.1.1 optional: true '@napi-rs/wasm-runtime@1.1.1': @@ -4284,6 +6067,79 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + + '@nuxt/kit@4.4.8': + dependencies: + c12: 3.3.4 + consola: 3.4.2 + defu: 6.1.7 + destr: 2.0.5 + errx: 0.1.2 + exsolve: 1.0.8 + ignore: 7.0.6 + jiti: 2.7.0 + klona: 2.0.6 + mlly: 1.8.2 + ohash: 2.0.11 + pathe: 2.0.3 + pkg-types: 2.3.1 + rc9: 3.0.1 + scule: 1.3.0 + semver: 7.8.5 + tinyglobby: 0.2.17 + ufo: 1.6.4 + unctx: 2.5.0 + untyped: 2.0.0 + transitivePeerDependencies: + - magicast + + '@oclif/core@4.11.4': + dependencies: + ansi-escapes: 4.3.2 + ansis: 3.17.0 + clean-stack: 3.0.1 + cli-spinners: 2.9.2 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + get-package-type: 0.1.0 + indent-string: 4.0.0 + is-wsl: 2.2.0 + lilconfig: 3.1.3 + minimatch: 10.2.6 + semver: 7.8.5 + string-width: 4.2.3 + supports-color: 8.1.1 + tinyglobby: 0.2.17 + widest-line: 3.1.0 + wordwrap: 1.0.0 + wrap-ansi: 7.0.0 + + '@oclif/plugin-help@6.2.37': + dependencies: + '@oclif/core': 4.11.4 + '@octokit/auth-app@7.2.2': dependencies: '@octokit/auth-oauth-app': 8.1.4 @@ -4548,8 +6404,6 @@ snapshots: '@petamoriken/float16@3.9.3': {} - '@pinojs/redact@0.4.0': {} - '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -5024,12 +6878,120 @@ snapshots: '@rollup/rollup-win32-ia32-msvc@4.57.1': optional: true - '@rollup/rollup-win32-x64-gnu@4.57.1': + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/is@7.2.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@smithy/core@3.32.0': + dependencies: + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.7.0': + dependencies: + '@smithy/core': 3.32.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.10.0': + dependencies: + '@smithy/core': 3.32.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.0': + dependencies: + '@smithy/core': 3.32.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + + '@smithy/types@4.17.0': + dependencies: + tslib: 2.8.1 + + '@standard-schema/spec@1.0.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0)': + dependencies: + '@swc/core': 1.15.3 + '@swc/counter': 0.1.3 + '@xhmikosr/bin-wrapper': 14.5.1 + commander: 8.3.0 + minimatch: 9.0.9 + piscina: 4.9.3 + semver: 7.8.5 + slash: 3.0.0 + source-map: 0.7.6 + tinyglobby: 0.2.17 + optionalDependencies: + chokidar: 5.0.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@swc/core-darwin-arm64@1.15.3': + optional: true + + '@swc/core-darwin-x64@1.15.3': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.3': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.3': + optional: true + + '@swc/core-linux-arm64-musl@1.15.3': + optional: true + + '@swc/core-linux-x64-gnu@1.15.3': + optional: true + + '@swc/core-linux-x64-musl@1.15.3': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.3': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.3': optional: true - '@rollup/rollup-win32-x64-msvc@4.57.1': + '@swc/core-win32-x64-msvc@1.15.3': optional: true + '@swc/core@1.15.3': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.3 + '@swc/core-darwin-x64': 1.15.3 + '@swc/core-linux-arm-gnueabihf': 1.15.3 + '@swc/core-linux-arm64-gnu': 1.15.3 + '@swc/core-linux-arm64-musl': 1.15.3 + '@swc/core-linux-x64-gnu': 1.15.3 + '@swc/core-linux-x64-musl': 1.15.3 + '@swc/core-win32-arm64-msvc': 1.15.3 + '@swc/core-win32-ia32-msvc': 1.15.3 + '@swc/core-win32-x64-msvc': 1.15.3 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.28': + dependencies: + '@swc/counter': 0.1.3 + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -5091,12 +7053,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) '@tanstack/history@1.154.14': {} @@ -5140,19 +7102,19 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tanstack/react-start@1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@tanstack/react-router': 1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start-client': 1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start-server': 1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-utils': 1.158.0 '@tanstack/start-client-core': 1.158.0 - '@tanstack/start-plugin-core': 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tanstack/start-plugin-core': 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) '@tanstack/start-server-core': 1.158.0(crossws@0.4.4(srvx@0.10.1)) pathe: 2.0.3 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -5190,7 +7152,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tanstack/router-plugin@1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5207,7 +7169,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5236,7 +7198,7 @@ snapshots: '@tanstack/start-fn-stubs@1.154.7': {} - '@tanstack/start-plugin-core@1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tanstack/start-plugin-core@1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 @@ -5244,7 +7206,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.158.0 '@tanstack/router-generator': 1.158.0 - '@tanstack/router-plugin': 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tanstack/router-plugin': 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) '@tanstack/router-utils': 1.158.0 '@tanstack/start-client-core': 1.158.0 '@tanstack/start-server-core': 1.158.0(crossws@0.4.4(srvx@0.10.1)) @@ -5254,8 +7216,8 @@ snapshots: srvx: 0.10.1 tinyglobby: 0.2.15 ufo: 1.6.3 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) - vitefu: 1.1.1(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + vitefu: 1.1.1(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) xmlbuilder2: 4.0.3 zod: 3.25.76 transitivePeerDependencies: @@ -5286,6 +7248,15 @@ snapshots: '@tanstack/virtual-file-routes@1.154.7': {} + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -5312,10 +7283,21 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} + '@types/http-cache-semantics@4.2.0': {} + '@types/json-schema@7.0.15': {} + '@types/ms@2.1.0': {} + '@types/node@20.19.31': dependencies: undici-types: 6.21.0 @@ -5332,7 +7314,135 @@ snapshots: dependencies: '@types/node': 20.19.31 - '@vitejs/plugin-react@5.1.3(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 + eslint: 9.39.2(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.2(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + debug: 4.4.3(supports-color@8.1.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.2(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.67.0': {} + + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + eslint: 9.39.2(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + + '@vercel/cli-auth@0.0.1': + dependencies: + async-listen: 3.0.0 + open: 8.4.0 + xdg-app-paths: 5.1.0 + zod: 4.1.11 + + '@vercel/cli-config@0.2.3': + 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.3(@aws-sdk/credential-provider-web-identity@3.972.49)': + dependencies: + '@vercel/oidc': 3.8.4 + optionalDependencies: + '@aws-sdk/credential-provider-web-identity': 3.972.49 + + '@vercel/oidc@3.2.0': {} + + '@vercel/oidc@3.8.4': + dependencies: + '@vercel/cli-config': 0.2.3 + '@vercel/cli-exec': 1.0.1 + jose: 5.10.0 + + '@vercel/queue@0.3.1': + dependencies: + '@vercel/oidc': 3.8.4 + minimatch: 10.2.6 + mixpart: 0.0.6 + picocolors: 1.1.1 + + '@vitejs/plugin-react@5.1.3(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -5340,11 +7450,407 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.2 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@workflow/astro@4.0.17': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.1.7 + '@workflow/rollup': 4.0.17 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + '@workflow/vite': 4.0.17 + exsolve: 1.0.8 + pathe: 2.0.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/builders@4.1.7': + dependencies: + '@swc/core': 1.15.3 + '@workflow/core': 4.8.2 + '@workflow/errors': 4.2.1 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + '@workflow/utils': 4.1.4 + builtin-modules: 5.0.0 + chalk: 5.6.2 + enhanced-resolve: 5.19.0 + esbuild: 0.28.2 + find-up: 7.0.0 + json5: 2.2.3 + tinyglobby: 0.2.17 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/cli@4.3.6': + dependencies: + '@oclif/core': 4.11.4 + '@oclif/plugin-help': 6.2.37 + '@swc/core': 1.15.3 + '@vercel/cli-auth': 0.0.1 + '@workflow/builders': 4.1.7 + '@workflow/core': 4.8.2 + '@workflow/errors': 4.2.1 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + '@workflow/utils': 4.1.4 + '@workflow/web': 4.1.18 + '@workflow/world': 4.3.1 + '@workflow/world-local': 4.2.4 + '@workflow/world-vercel': 4.6.2 + boxen: 8.0.1 + builtin-modules: 5.0.0 + chalk: 5.6.2 + chokidar: 4.0.3 + date-fns: 4.1.0 + dotenv: 17.4.2 + easy-table: 1.2.0 + enhanced-resolve: 5.19.0 + esbuild: 0.28.2 + find-up: 7.0.0 + mixpart: 0.0.4 + open: 10.2.0 + ora: 8.2.0 + terminal-link: 5.0.0 + tinyglobby: 0.2.17 + xdg-app-paths: 5.1.0 + zod: 4.3.6 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/core@4.8.2': + dependencies: + '@aws-sdk/credential-provider-web-identity': 3.972.49 + '@jridgewell/trace-mapping': 0.3.31 + '@standard-schema/spec': 1.0.0 + '@types/ms': 2.1.0 + '@vercel/functions': 3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49) + '@workflow/errors': 4.2.1 + '@workflow/serde': 4.1.2 + '@workflow/utils': 4.1.4 + '@workflow/world': 4.3.1 + '@workflow/world-local': 4.2.4 + '@workflow/world-vercel': 4.6.2 + debug: 4.4.3(supports-color@8.1.1) + devalue: 5.8.1 + ms: 2.1.3 + nanoid: 5.1.6 + seedrandom: 3.0.5 + semver: 7.7.4 + ulid: 3.0.2 + zod: 4.3.6 + transitivePeerDependencies: + - supports-color + - ws + + '@workflow/errors@4.2.1': + dependencies: + '@workflow/utils': 4.1.4 + ms: 2.1.3 + + '@workflow/nest@4.0.18(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)': + dependencies: + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@swc/cli': 0.8.1(@swc/core@1.15.3)(chokidar@5.0.0) + '@swc/core': 1.15.3 + '@workflow/builders': 4.1.7 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + pathe: 2.0.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/next@4.1.6': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.1.7 + '@workflow/core': 4.8.2 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + semver: 7.7.4 + watchpack: 2.5.1 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/nitro@4.1.8': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.1.7 + '@workflow/core': 4.8.2 + '@workflow/rollup': 4.0.17 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + '@workflow/vite': 4.0.17 + '@workflow/web': 4.1.18 + exsolve: 1.0.8 + pathe: 2.0.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/nuxt@4.0.18': + dependencies: + '@nuxt/kit': 4.4.8 + '@workflow/nitro': 4.1.8 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - magicast + - supports-color + - ws + + '@workflow/rollup@4.0.17': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.1.7 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + exsolve: 1.0.7 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/serde@4.1.2': {} + + '@workflow/sveltekit@4.0.17': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.1.7 + '@workflow/rollup': 4.0.17 + '@workflow/swc-plugin': 4.1.2(@swc/core@1.15.3) + '@workflow/vite': 4.0.17 + exsolve: 1.0.8 + fs-extra: 11.4.0 + pathe: 2.0.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/swc-plugin@4.1.2(@swc/core@1.15.3)': + dependencies: + '@swc/core': 1.15.3 + + '@workflow/typescript-plugin@4.0.3(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@workflow/utils@4.1.4': + dependencies: + ms: 2.1.3 + + '@workflow/vite@4.0.17': + dependencies: + '@workflow/builders': 4.1.7 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/vitest@4.0.18(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.1.10(@types/node@20.19.31)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)))': + dependencies: + '@workflow/builders': 4.1.7 + '@workflow/core': 4.8.2 + '@workflow/rollup': 4.0.17 + '@workflow/world': 4.3.1 + '@workflow/world-local': 4.2.4 + vitest: 4.1.10(@types/node@20.19.31)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) + optionalDependencies: + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + transitivePeerDependencies: + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + - ws + + '@workflow/web@4.1.18': + dependencies: + express: 5.2.1 + transitivePeerDependencies: + - supports-color + + '@workflow/world-local@4.2.4': + dependencies: + '@vercel/queue': 0.3.1 + '@workflow/errors': 4.2.1 + '@workflow/utils': 4.1.4 + '@workflow/world': 4.3.1 + async-sema: 3.1.1 + ulid: 3.0.2 + undici: 7.28.0 + zod: 4.3.6 + + '@workflow/world-vercel@4.6.2': + dependencies: + '@vercel/oidc': 3.2.0 + '@vercel/queue': 0.3.1 + '@workflow/errors': 4.2.1 + '@workflow/world': 4.3.1 + cbor-x: 1.6.0 + undici: 7.28.0 + zod: 4.3.6 + + '@workflow/world@4.3.1': + dependencies: + ulid: 3.0.2 + zod: 4.3.6 + + '@xhmikosr/archive-type@8.1.0': + dependencies: + file-type: 21.3.4 + transitivePeerDependencies: + - supports-color + + '@xhmikosr/bin-check@8.2.2': + dependencies: + execa: 9.6.1 + isexe: 4.0.0 + + '@xhmikosr/bin-wrapper@14.5.1': + dependencies: + '@xhmikosr/bin-check': 8.2.2 + '@xhmikosr/downloader': 16.3.1 + '@xhmikosr/os-filter-obj': 4.1.0 + binary-version-check: 6.1.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/decompress-tar@9.0.2': + dependencies: + file-type: 21.3.4 + is-stream: 4.0.1 + tar-stream: 3.1.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/decompress-tarbz2@9.0.2': + dependencies: + '@xhmikosr/decompress-tar': 9.0.2 + file-type: 21.3.4 + is-stream: 4.0.1 + seek-bzip: 2.0.0 + unbzip2-stream: 1.4.3 transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a - supports-color - abstract-logging@2.0.1: {} + '@xhmikosr/decompress-targz@9.0.1': + dependencies: + '@xhmikosr/decompress-tar': 9.0.2 + file-type: 21.3.4 + is-stream: 4.0.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/decompress-unzip@8.2.1': + dependencies: + file-type: 21.3.4 + get-stream: 9.0.1 + yauzl: 3.4.0 + transitivePeerDependencies: + - supports-color + + '@xhmikosr/decompress@11.1.4': + dependencies: + '@xhmikosr/decompress-tar': 9.0.2 + '@xhmikosr/decompress-tarbz2': 9.0.2 + '@xhmikosr/decompress-targz': 9.0.1 + '@xhmikosr/decompress-unzip': 8.2.1 + graceful-fs: 4.2.11 + strip-dirs: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/downloader@16.3.1': + dependencies: + '@xhmikosr/archive-type': 8.1.0 + '@xhmikosr/decompress': 11.1.4 + content-disposition: 2.0.1 + ext-name: 5.0.0 + file-type: 21.3.4 + filenamify: 7.0.2 + got: 14.6.6 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/os-filter-obj@4.1.0': + dependencies: + system-architecture: 1.0.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -5352,11 +7858,9 @@ snapshots: acorn@8.15.0: {} - agent-base@7.1.4: {} + acorn@8.18.0: {} - ajv-formats@3.0.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 + agent-base@7.1.4: {} ajv@6.12.6: dependencies: @@ -5365,20 +7869,31 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: + ansi-align@3.0.1: dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 + string-width: 4.2.3 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - ansis@4.2.0: {} + ansi-styles@6.2.3: {} + + ansis@3.17.0: {} - any-promise@1.3.0: {} + ansis@4.2.0: {} anymatch@3.1.3: dependencies: @@ -5398,16 +7913,19 @@ snapshots: minimalistic-assert: 1.0.1 safer-buffer: 2.1.2 + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 - atomic-sleep@1.0.0: {} + async-listen@3.0.0: {} - avvio@9.1.0: - dependencies: - '@fastify/error': 4.2.0 - fastq: 1.20.1 + async-sema@3.1.1: {} + + async@3.2.6: {} + + b4a@1.8.1: {} babel-dead-code-elimination@1.0.12: dependencies: @@ -5420,21 +7938,73 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + + bare-events@2.9.1: {} + + base64-js@1.5.1: {} + baseline-browser-mapping@2.9.19: {} before-after-hook@3.0.2: {} binary-extensions@2.3.0: {} + binary-version-check@6.1.0: + dependencies: + binary-version: 7.1.0 + semver: 7.8.5 + semver-truncate: 3.0.0 + + binary-version@7.1.0: + dependencies: + execa: 8.0.1 + find-versions: 6.0.0 + bn.js@4.12.2: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3(supports-color@8.1.1) + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} + bowser@2.14.1: {} + + boxen@8.0.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 8.0.0 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -5447,38 +8017,93 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) - buffer-equal-constant-time@1.0.1: {} + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-modules@5.0.0: {} + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + byte-counter@0.1.0: {} - buffer-from@1.1.2: {} + bytes@3.1.2: {} - bullmq@5.67.2: + c12@3.3.4: dependencies: - cron-parser: 4.9.0 - ioredis: 5.9.2 - msgpackr: 1.11.5 - node-abort-controller: 3.1.1 - semver: 7.7.3 - tslib: 2.8.1 - uuid: 11.1.0 - transitivePeerDependencies: - - supports-color + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.0.8 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 - bundle-require@5.1.0(esbuild@0.27.2): + cacheable-lookup@7.0.0: {} + + cacheable-request@13.0.19: dependencies: - esbuild: 0.27.2 - load-tsconfig: 0.2.5 + '@types/http-cache-semantics': 4.2.0 + get-stream: 9.0.1 + http-cache-semantics: 4.2.0 + keyv: 5.6.0 + mimic-response: 4.0.0 + normalize-url: 8.1.1 + responselike: 4.0.2 - cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 callsites@3.1.0: {} + camelcase@8.0.0: {} + caniuse-lite@1.0.30001767: {} + cbor-extract@2.2.2: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 + '@cbor-extract/cbor-extract-linux-x64': 2.2.2 + '@cbor-extract/cbor-extract-win32-x64': 2.2.2 + optional: true + + cbor-x@1.6.0: + optionalDependencies: + cbor-extract: 2.2.2 + + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -5518,13 +8143,37 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 + clean-stack@3.0.1: + dependencies: + escape-string-regexp: 4.0.0 + + cli-boxes@3.0.0: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + clone@1.0.4: + optional: true + clsx@2.1.1: {} - cluster-key-slot@1.1.2: {} + cluster-key-slot@1.1.2: + optional: true color-convert@2.0.1: dependencies: @@ -5532,25 +8181,35 @@ snapshots: color-name@1.1.4: {} - colorette@2.0.20: {} + commander@6.2.1: {} - commander@4.1.1: {} + commander@8.3.0: {} concat-map@0.0.1: {} confbox@0.1.8: {} + confbox@0.2.4: {} + consola@3.4.2: {} + content-disposition@1.1.0: {} + + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + convert-hrtime@5.0.0: {} + convert-source-map@2.0.0: {} cookie-es@2.0.0: {} - cookie@1.1.1: {} + cookie-signature@1.2.2: {} - cron-parser@4.9.0: - dependencies: - luxon: 3.7.2 + cookie@0.7.2: {} cross-spawn@7.0.6: dependencies: @@ -5574,26 +8233,56 @@ snapshots: csstype@3.2.3: {} - dateformat@4.6.3: {} + date-fns@4.1.0: {} - db0@0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)): + db0@0.3.4(@electric-sql/pglite@0.2.17)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)): optionalDependencies: - drizzle-orm: 0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) + '@electric-sql/pglite': 0.2.17 + drizzle-orm: 0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4) - debug@4.4.3: + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decompress-response@10.0.0: + dependencies: + mimic-response: 4.0.0 deep-is@0.1.4: {} - denque@2.1.0: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + optional: true + + define-lazy-prop@2.0.0: {} + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + + denque@2.1.0: + optional: true - dequal@2.0.3: {} + depd@2.0.0: {} + + destr@2.0.5: {} detect-libc@2.1.2: {} detect-node-es@1.1.0: {} + devalue@5.8.1: {} + diff@8.0.3: {} dom-serializer@2.0.0: @@ -5614,6 +8303,8 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dotenv@17.4.2: {} + drizzle-kit@0.30.6: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -5624,27 +8315,48 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4): + drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4): optionalDependencies: + '@electric-sql/pglite': 0.2.17 '@types/react': 19.2.10 postgres: 3.4.8 react: 19.2.4 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + easy-table@1.2.0: + dependencies: + ansi-regex: 5.0.1 + optionalDependencies: + wcwidth: 1.0.1 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 + ee-first@1.1.1: {} + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + electron-to-chromium@1.5.286: {} + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 whatwg-encoding: 3.1.1 - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 @@ -5658,9 +8370,23 @@ snapshots: env-paths@3.0.0: {} + environment@1.1.0: {} + + errx@0.1.2: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild-register@3.6.0(esbuild@0.19.12): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) esbuild: 0.19.12 transitivePeerDependencies: - supports-color @@ -5745,8 +8471,39 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-scope@8.4.0: @@ -5758,9 +8515,11 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2(jiti@2.6.1): + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.2(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -5775,7 +8534,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -5795,7 +8554,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -5817,91 +8576,184 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} - exsolve@1.0.8: {} + etag@1.8.1: {} - fast-content-type-parse@2.0.1: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller - fast-copy@4.0.2: {} + 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 - fast-decode-uri-component@1.0.1: {} + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + + expect-type@1.4.0: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@8.1.1) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color - fast-deep-equal@3.1.3: {} + exsolve@1.0.7: {} - fast-json-stable-stringify@2.1.0: {} + exsolve@1.0.8: {} - fast-json-stringify@6.2.0: + ext-list@2.2.2: dependencies: - '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - fast-uri: 3.1.0 - json-schema-ref-resolver: 3.0.0 - rfdc: 1.4.1 - - fast-levenshtein@2.0.6: {} + mime-db: 1.54.0 - fast-querystring@1.1.2: + ext-name@5.0.0: dependencies: - fast-decode-uri-component: 1.0.1 + ext-list: 2.2.2 + sort-keys-length: 1.0.1 - fast-safe-stringify@2.1.1: {} + fast-content-type-parse@2.0.1: {} - fast-uri@3.1.0: {} - - fastify-plugin@5.1.0: {} - - fastify@5.7.4: - dependencies: - '@fastify/ajv-compiler': 4.0.5 - '@fastify/error': 4.2.0 - '@fastify/fast-json-stringify-compiler': 5.0.3 - '@fastify/proxy-addr': 5.1.0 - abstract-logging: 2.0.1 - avvio: 9.1.0 - fast-json-stringify: 6.2.0 - find-my-way: 9.4.0 - light-my-request: 6.6.0 - pino: 10.3.0 - process-warning: 5.0.0 - rfdc: 1.4.1 - secure-json-parse: 4.1.0 - semver: 7.7.3 - toad-cache: 3.7.0 + fast-deep-equal@3.1.3: {} - fastq@1.20.1: - dependencies: - reusify: 1.1.0 + fast-fifo@1.3.2: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-safe-stringify@2.1.1: {} fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + filename-reserved-regex@4.0.0: {} + + filenamify@7.0.2: + dependencies: + filename-reserved-regex: 4.0.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - find-my-way@9.4.0: + finalhandler@2.1.1: dependencies: - fast-deep-equal: 3.1.3 - fast-querystring: 1.1.2 - safe-regex2: 5.0.0 + debug: 4.4.3(supports-color@8.1.1) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color find-up@5.0.0: dependencies: locate-path: 6.0.0 path-exists: 4.0.0 - fix-dts-default-cjs-exports@1.0.1: + find-up@7.0.0: dependencies: - magic-string: 0.30.21 - mlly: 1.8.0 - rollup: 4.57.1 + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + + find-versions@6.0.0: + dependencies: + semver-regex: 4.0.5 + super-regex: 1.1.0 flat-cache@4.0.1: dependencies: @@ -5910,13 +8762,29 @@ snapshots: flatted@3.3.3: {} + form-data-encoder@4.1.0: {} + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + function-timeout@1.0.2: {} + gel@2.2.0: dependencies: '@petamoriken/float16': 3.9.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) env-paths: 3.0.0 semver: 7.7.3 shell-quote: 1.8.3 @@ -5926,12 +8794,45 @@ snapshots: gensync@1.0.0-beta.2: {} + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + get-stream@8.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@4.13.1: dependencies: resolve-pkg-maps: 1.0.0 + giget@3.3.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5940,10 +8841,29 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} + globals@14.0.0: {} globrex@0.1.2: {} + gopd@1.2.0: {} + + got@14.6.6: + dependencies: + '@sindresorhus/is': 7.2.0 + byte-counter: 0.1.0 + cacheable-lookup: 7.0.0 + cacheable-request: 13.0.19 + decompress-response: 10.0.0 + form-data-encoder: 4.1.0 + http2-wrapper: 2.2.1 + keyv: 5.6.0 + lowercase-keys: 3.0.0 + p-cancelable: 4.0.1 + responselike: 4.0.2 + type-fest: 4.41.0 + graceful-fs@4.2.11: {} h3@2.0.1-rc.11(crossws@0.4.4(srvx@0.10.1)): @@ -5955,7 +8875,13 @@ snapshots: has-flag@4.0.0: {} - help-me@5.0.0: {} + has-flag@5.0.1: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 htmlparser2@10.1.0: dependencies: @@ -5964,21 +8890,50 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + http-cache-semantics@4.2.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http2-wrapper@2.2.1: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + http_ece@1.2.0: {} https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color + human-signals@2.1.0: {} + + human-signals@5.0.0: {} + + human-signals@8.0.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + ignore@5.3.2: {} + ignore@7.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -5986,13 +8941,19 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inherits@2.0.4: {} + inspect-with-kind@1.0.5: + dependencies: + kind-of: 6.0.3 + ioredis@5.9.2: dependencies: '@ioredis/commands': 1.5.0 cluster-key-slot: 1.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -6001,30 +8962,81 @@ snapshots: standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color + optional: true - ipaddr.js@2.3.0: {} + ipaddr.js@1.9.1: {} is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + is-number@7.0.0: {} + is-plain-obj@1.1.0: {} + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-stream@2.0.1: {} + + is-stream@3.0.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isbot@5.1.34: {} isexe@2.0.0: {} isexe@3.1.1: {} + isexe@4.0.0: {} + + iterare@1.2.1: {} + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + jiti@2.6.1: {} - joycon@3.1.1: {} + jiti@2.7.0: {} + + jose@5.10.0: {} + + jose@6.2.8: {} js-tokens@4.0.0: {} @@ -6036,18 +9048,18 @@ snapshots: json-buffer@3.0.1: {} - json-schema-ref-resolver@3.0.0: - dependencies: - dequal: 2.0.3 - json-schema-traverse@0.4.1: {} - json-schema-traverse@1.0.0: {} - json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -6063,17 +9075,21 @@ snapshots: dependencies: json-buffer: 3.0.1 + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + klona@2.0.6: {} + + knitwork@1.3.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 - light-my-request@6.6.0: - dependencies: - cookie: 1.1.1 - process-warning: 4.0.1 - set-cookie-parser: 2.7.2 - lightningcss-android-arm64@1.30.2: optional: true @@ -6125,20 +9141,31 @@ snapshots: lilconfig@3.1.3: {} - lines-and-columns@1.2.4: {} - - load-tsconfig@0.2.5: {} + load-esm@1.0.3: {} locate-path@6.0.0: dependencies: p-locate: 5.0.0 - lodash.defaults@4.2.0: {} + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash.defaults@4.2.0: + optional: true - lodash.isarguments@3.1.0: {} + lodash.isarguments@3.1.0: + optional: true lodash.merge@4.6.2: {} + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + lowercase-keys@3.0.0: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -6147,24 +9174,62 @@ snapshots: dependencies: react: 19.2.4 - luxon@3.7.2: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-asynchronous@1.1.0: + dependencies: + p-event: 6.0.1 + type-fest: 4.41.0 + web-worker: 1.5.0 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + mimic-response@4.0.0: {} + minimalistic-assert@1.0.1: {} - minimatch@10.1.2: + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.9: dependencies: - '@isaacs/brace-expansion': 5.0.1 + brace-expansion: 2.1.4 - minimatch@3.1.2: + minimatch@9.0.9: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 2.1.4 minimist@1.2.8: {} + mixpart@0.0.4: {} + + mixpart@0.0.6: {} + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -6172,45 +9237,30 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 - mnemonist@0.40.0: + mlly@1.8.2: dependencies: - obliterator: 2.0.5 + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 ms@2.1.3: {} - msgpackr-extract@3.0.3: - dependencies: - node-gyp-build-optional-packages: 5.2.2 - optionalDependencies: - '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 - optional: true - - msgpackr@1.11.5: - optionalDependencies: - msgpackr-extract: 3.0.3 - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - nanoid@3.3.11: {} + nanoid@5.1.6: {} + natural-compare@1.4.0: {} + negotiator@1.0.0: {} + nf3@0.3.7: {} - nitro@3.0.1-alpha.2(chokidar@4.0.3)(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + nitro@3.0.1-alpha.2(@electric-sql/pglite@0.2.17)(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4))(ioredis@5.9.2)(rollup@4.57.1)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: consola: 3.4.2 crossws: 0.4.4(srvx@0.10.1) - db0: 0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)) + db0: 0.3.4(@electric-sql/pglite@0.2.17)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)) h3: 2.0.1-rc.11(crossws@0.4.4(srvx@0.10.1)) jiti: 2.6.1 nf3: 0.3.7 @@ -6221,10 +9271,10 @@ snapshots: srvx: 0.10.1 undici: 7.20.0 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.5(chokidar@4.0.3)(db0@0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.5(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.2.17)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3) optionalDependencies: rollup: 4.57.1 - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -6254,9 +9304,7 @@ snapshots: - sqlite3 - uploadthing - node-abort-controller@3.1.1: {} - - node-gyp-build-optional-packages@5.2.2: + node-gyp-build-optional-packages@5.1.1: dependencies: detect-libc: 2.1.2 optional: true @@ -6265,24 +9313,66 @@ snapshots: normalize-path@3.0.0: {} + normalize-url@8.1.1: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 - object-assign@4.1.1: {} + object-inspect@1.13.4: {} - obliterator@2.0.5: {} + obug@2.1.4: {} ofetch@2.0.0-alpha.3: {} ohash@2.0.11: {} - on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 once@1.4.0: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + open@8.4.0: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6292,6 +9382,20 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + os-paths@4.4.0: {} + oxc-minify@0.110.0: optionalDependencies: '@oxc-minify/binding-android-arm-eabi': 0.110.0 @@ -6338,18 +9442,36 @@ snapshots: '@oxc-transform/binding-win32-ia32-msvc': 0.110.0 '@oxc-transform/binding-win32-x64-msvc': 0.110.0 + p-cancelable@4.0.1: {} + + p-event@6.0.1: + dependencies: + p-timeout: 6.1.4 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + p-timeout@6.1.4: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse-ms@4.0.0: {} + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -6363,55 +9485,35 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + path-exists@4.0.0: {} + path-exists@5.0.0: {} + path-key@3.1.1: {} + path-key@4.0.0: {} + + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + pend@1.2.0: {} + + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} picomatch@4.0.3: {} - pino-abstract-transport@3.0.0: - dependencies: - split2: 4.2.0 + picomatch@4.0.5: {} - pino-pretty@13.1.3: - dependencies: - colorette: 2.0.20 - dateformat: 4.6.3 - fast-copy: 4.0.2 - fast-safe-stringify: 2.1.1 - help-me: 5.0.0 - joycon: 3.1.1 - minimist: 1.2.8 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pump: 3.0.3 - secure-json-parse: 4.1.0 - sonic-boom: 4.2.0 - strip-json-comments: 5.0.3 - - pino-std-serializers@7.1.0: {} - - pino@10.3.0: - dependencies: - '@pinojs/redact': 0.4.0 - atomic-sleep: 1.0.0 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pino-std-serializers: 7.1.0 - process-warning: 5.0.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.0 - thread-stream: 4.0.0 - - pirates@4.0.7: {} + piscina@4.9.3: + optionalDependencies: + '@napi-rs/nice': 1.1.1 pkg-types@1.3.1: dependencies: @@ -6419,13 +9521,11 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0): + pkg-types@2.3.1: dependencies: - lilconfig: 3.1.3 - optionalDependencies: - jiti: 2.6.1 - postcss: 8.5.6 - tsx: 4.21.0 + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 postcss@8.5.6: dependencies: @@ -6439,18 +9539,37 @@ snapshots: prettier@3.8.1: {} - process-warning@4.0.1: {} - - process-warning@5.0.0: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 - pump@3.0.3: + proxy-addr@2.0.7: dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 + forwarded: 0.2.0 + ipaddr.js: 1.9.1 punycode@2.3.1: {} - quick-format-unescaped@4.0.4: {} + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + quick-lru@5.1.1: {} + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 react-dom@19.2.4(react@19.2.4): dependencies: @@ -6494,7 +9613,7 @@ snapshots: readdirp@4.1.2: {} - real-require@0.2.0: {} + readdirp@5.1.1: {} recast@0.23.11: dependencies: @@ -6504,25 +9623,30 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - redis-errors@1.2.0: {} + redis-errors@1.2.0: + optional: true redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 + optional: true - require-from-string@2.0.2: {} + reflect-metadata@0.2.2: {} - resolve-from@4.0.0: {} + resolve-alpn@1.2.1: {} - resolve-from@5.0.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} - ret@0.5.0: {} - - reusify@1.1.0: {} + responselike@4.0.2: + dependencies: + lowercase-keys: 3.0.0 - rfdc@1.4.1: {} + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 rollup@4.57.1: dependencies: @@ -6557,31 +9681,82 @@ snapshots: rou3@0.7.12: {} - safe-buffer@5.2.1: {} + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} - safe-regex2@5.0.0: + rxjs@7.8.2: dependencies: - ret: 0.5.0 + tslib: 2.8.1 - safe-stable-stringify@2.5.0: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} scheduler@0.27.0: {} - secure-json-parse@4.1.0: {} + scule@1.3.0: {} + + seedrandom@3.0.5: {} + + seek-bzip@2.0.0: + dependencies: + commander: 6.2.1 + + semver-regex@4.0.5: {} + + semver-truncate@3.0.0: + dependencies: + semver: 7.8.5 semver@6.3.1: {} semver@7.7.3: {} + semver@7.7.4: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + seroval-plugins@1.5.0(seroval@1.5.0): dependencies: seroval: 1.5.0 seroval@1.5.0: {} - set-cookie-parser@2.7.2: {} + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} shebang-command@2.0.0: dependencies: @@ -6591,9 +9766,49 @@ snapshots: shell-quote@1.8.3: {} - sonic-boom@4.2.0: + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + 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 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + sort-keys-length@1.0.1: + dependencies: + sort-keys: 1.1.2 + + sort-keys@1.1.2: dependencies: - atomic-sleep: 1.0.0 + is-plain-obj: 1.1.0 source-map-js@1.2.1: {} @@ -6606,68 +9821,157 @@ snapshots: source-map@0.7.6: {} - split2@4.2.0: {} - srvx@0.10.1: {} - standard-as-callback@2.1.0: {} + stackback@0.0.2: {} + + standard-as-callback@2.1.0: + optional: true + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + stdin-discarder@0.2.2: {} + + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + strip-dirs@3.0.0: + dependencies: + inspect-with-kind: 1.0.5 + is-plain-obj: 1.1.0 + + strip-final-newline@2.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-final-newline@4.0.0: {} strip-json-comments@3.1.1: {} - strip-json-comments@5.0.3: {} + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 - sucrase@3.35.1: + super-regex@1.1.0: dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.15 - ts-interface-checker: 0.1.13 + function-timeout: 1.0.2 + make-asynchronous: 1.1.0 + time-span: 5.1.0 + + supports-color@10.2.2: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@4.5.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + + system-architecture@1.0.0: {} + tailwind-merge@2.6.1: {} tailwindcss@4.1.18: {} tapable@2.3.0: {} - thenify-all@1.6.0: + tar-stream@3.1.7: dependencies: - thenify: 3.3.1 + b4a: 1.8.1 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + terminal-link@5.0.0: + dependencies: + ansi-escapes: 7.3.0 + supports-hyperlinks: 4.5.0 - thenify@3.3.1: + text-decoder@1.2.7: dependencies: - any-promise: 1.3.0 + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a - thread-stream@4.0.0: + through@2.3.8: {} + + time-span@5.1.0: dependencies: - real-require: 0.2.0 + convert-hrtime: 5.0.0 tiny-invariant@1.3.3: {} tiny-warning@1.0.3: {} - tinyexec@0.3.2: {} + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toad-cache@3.7.0: {} - tree-kill@1.2.2: {} + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 - ts-interface-checker@0.1.13: {} + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: @@ -6675,34 +9979,6 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): - dependencies: - bundle-require: 5.1.0(esbuild@0.27.2) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.3 - esbuild: 0.27.2 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0) - resolve-from: 5.0.0 - rollup: 4.57.1 - source-map: 0.7.6 - sucrase: 3.35.1 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tree-kill: 1.2.2 - optionalDependencies: - postcss: 8.5.6 - typescript: 5.9.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - tsx@4.21.0: dependencies: esbuild: 0.27.2 @@ -6741,36 +10017,98 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript-eslint@8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} ufo@1.6.3: {} + ufo@1.6.4: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + ulid@3.0.2: {} + + unbzip2-stream@1.4.3: + dependencies: + buffer: 5.7.1 + through: 2.3.8 + + unctx@2.5.0: + dependencies: + acorn: 8.15.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + unplugin: 2.3.11 + undici-types@6.21.0: {} undici@7.20.0: {} + undici@7.28.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 + unicorn-magic@0.1.0: {} + + unicorn-magic@0.3.0: {} + universal-github-app-jwt@2.2.2: {} universal-user-agent@7.0.3: {} + universalify@2.0.1: {} + + unpipe@1.0.0: {} + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.15.0 - picomatch: 4.0.3 + picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 - unstorage@2.0.0-alpha.5(chokidar@4.0.3)(db0@0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.5(@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49))(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.2.17)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)))(ioredis@5.9.2)(ofetch@2.0.0-alpha.3): optionalDependencies: - chokidar: 4.0.3 - db0: 0.3.4(drizzle-orm@0.38.4(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)) + '@vercel/functions': 3.9.3(@aws-sdk/credential-provider-web-identity@3.972.49) + chokidar: 5.0.0 + db0: 0.3.4(@electric-sql/pglite@0.2.17)(drizzle-orm@0.38.4(@electric-sql/pglite@0.2.17)(@types/react@19.2.10)(postgres@3.4.8)(react@19.2.4)) ioredis: 5.9.2 ofetch: 2.0.0-alpha.3 + untyped@2.0.0: + dependencies: + citty: 0.1.6 + defu: 6.1.7 + jiti: 2.7.0 + knitwork: 1.3.0 + scule: 1.3.0 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -6800,20 +10138,20 @@ snapshots: dependencies: react: 19.2.4 - uuid@11.1.0: {} + vary@1.1.2: {} - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - supports-color - typescript - vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -6824,13 +10162,50 @@ snapshots: optionalDependencies: '@types/node': 20.19.31 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 lightningcss: 1.30.2 tsx: 4.21.0 - vitefu@1.1.1(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + vitefu@1.1.1(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)): + optionalDependencies: + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + + vitest@4.1.10(@types/node@20.19.31)(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 7.3.1(@types/node@20.19.31)(jiti@2.7.0)(lightningcss@1.30.2)(tsx@4.21.0) + why-is-node-running: 2.3.0 optionalDependencies: - vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + '@types/node': 20.19.31 + transitivePeerDependencies: + - msw + + watchpack@2.5.1: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + optional: true web-push@3.6.7: dependencies: @@ -6842,6 +10217,8 @@ snapshots: transitivePeerDependencies: - supports-color + web-worker@1.5.0: {} + webpack-virtual-modules@0.6.2: {} whatwg-encoding@3.1.1: @@ -6858,10 +10235,81 @@ snapshots: dependencies: isexe: 3.1.1 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + word-wrap@1.2.5: {} + wordwrap@1.0.0: {} + + workflow@4.8.2(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)(typescript@5.9.3): + dependencies: + '@workflow/astro': 4.0.17 + '@workflow/cli': 4.3.6 + '@workflow/core': 4.8.2 + '@workflow/errors': 4.2.1 + '@workflow/nest': 4.0.18(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3) + '@workflow/next': 4.1.6 + '@workflow/nitro': 4.1.8 + '@workflow/nuxt': 4.0.18 + '@workflow/rollup': 4.0.17 + '@workflow/sveltekit': 4.0.17 + '@workflow/typescript-plugin': 4.0.3(typescript@5.9.3) + '@workflow/utils': 4.1.4 + ms: 2.1.3 + transitivePeerDependencies: + - '@nestjs/common' + - '@nestjs/core' + - '@swc/cli' + - '@swc/core' + - '@swc/helpers' + - magicast + - next + - supports-color + - typescript + - ws + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xdg-app-paths@5.1.0: + dependencies: + xdg-portable: 7.3.0 + + 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 + xmlbuilder2@4.0.3: dependencies: '@oozcitak/dom': 2.0.2 @@ -6871,6 +10319,18 @@ snapshots: yallist@3.1.1: {} + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + + yoctocolors@2.2.0: {} + zod@3.25.76: {} + + zod@4.1.11: {} + + zod@4.3.6: {} diff --git a/railway.json b/railway.json deleted file mode 100644 index 407ee3b..0000000 --- a/railway.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "deploy": { - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 3 - } -} diff --git a/turbo.json b/turbo.json index 89de6e5..a53ee55 100644 --- a/turbo.json +++ b/turbo.json @@ -17,6 +17,14 @@ "typecheck": { "dependsOn": ["^build"] }, + "test": { + "dependsOn": ["^build"], + "outputs": [] + }, + "test:integration": { + "dependsOn": ["^build"], + "outputs": [] + }, "clean": { "cache": false }, diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..29ddd96 --- /dev/null +++ b/vercel.json @@ -0,0 +1,6 @@ +{ + "crons": [ + { "path": "/api/cron/prune-branches", "schedule": "0 */6 * * *" }, + { "path": "/api/cron/cleanup-events", "schedule": "0 3 * * *" } + ] +}