cleaning repo and saving update

This commit is contained in:
Luke Hagar
2025-08-14 16:02:33 +00:00
parent 30aedd3794
commit ca7d86ed54
26 changed files with 1904 additions and 473 deletions

25
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:22
# Install pnpm via corepack; keep git available for typical workflows
# Also install native build deps required by node-canvas used for server-side chart rendering
RUN corepack enable && corepack prepare pnpm@9.12.3 --activate \
&& apt-get update \
&& apt-get install -y \
git \
build-essential \
libcairo2-dev \
libpango1.0-dev \
libjpeg-dev \
libgif-dev \
librsvg2-dev \
libpng-dev \
libpixman-1-dev \
pkg-config \
python3 \
&& rm -rf /var/lib/apt/lists/*
USER node
WORKDIR /workspace

View File

@@ -1,33 +1,18 @@
{ {
"name": "pypistats.dev", "name": "Vite Dev + Postgres + Redis",
"dockerComposeFile": [ "dockerComposeFile": "docker-compose.yml",
"../docker-compose.yml", "service": "app",
"../docker-compose.dev.yml" "workspaceFolder": "/workspace",
], "settings": {
"service": "web", "terminal.integrated.defaultProfile.linux": "bash"
"workspaceFolder": "/app",
"build": {
"args": {
"SKIP_APP_BUILD": "1"
}
}, },
"runArgs": ["--env-file", ".env"], "postCreateCommand": "corepack enable && corepack prepare pnpm@9.12.3 --activate && pnpm install && pnpm prisma generate && pnpm prisma migrate deploy && (pnpm rebuild canvas || true) && pnpm dev",
"forwardPorts": [5173, 3000, 5555], "remoteUser": "root",
"portsAttributes": { "forwardPorts": [5173, 5432, 6379],
"5173": { "label": "Vite Dev Server" }, "extensions": [
"3000": { "label": "Node Adapter Server" }, "esbenp.prettier-vscode",
"5555": { "label": "Prisma Studio" } "dbaeumer.vscode-eslint"
}, ]
"customizations": {
"vscode": {
"extensions": [
"esbenp.prettier-vscode",
"Prisma.prisma",
"svelte.svelte-vscode",
"dbaeumer.vscode-eslint"
]
}
}
} }

View File

@@ -0,0 +1,47 @@
version: "3.8"
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ..:/workspace:cached
command: sleep infinity
depends_on:
db:
condition: service_started
redis:
condition: service_started
ports:
- "5173:5173"
environment:
# Use service DNS names within the devcontainer network
DATABASE_URL: postgresql://postgres:postgres@db:5432/postgres?schema=public
REDIS_URL: redis://redis:6379
networks:
- devnet
db:
image: postgres:15
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- "5432:5432"
networks:
- devnet
redis:
image: redis:7
restart: unless-stopped
ports:
- "6379:6379"
networks:
- devnet
networks:
devnet:

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules node_modules
.pnpm-store
# Output # Output
.output .output

View File

@@ -1,13 +1,10 @@
FROM node:20-slim FROM node:latest
# Install deps needed by Prisma and shell # Install deps needed by Prisma and shell
RUN apt-get update && apt-get install -y openssl bash && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y openssl bash && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Allow skipping app build in devcontainer
ARG SKIP_APP_BUILD=0
# Copy package manifests first for better cache # Copy package manifests first for better cache
COPY package.json pnpm-lock.yaml* ./ COPY package.json pnpm-lock.yaml* ./
@@ -21,17 +18,15 @@ RUN pnpm install --frozen-lockfile
COPY . . COPY . .
# Generate Prisma client and build SvelteKit (Node adapter) # Generate Prisma client and build SvelteKit (Node adapter)
RUN pnpm prisma generate RUN pnpm prisma generate && pnpm prisma migrate deploy
RUN if [ "$SKIP_APP_BUILD" != "1" ]; then pnpm build; fi
RUN pnpm build
ENV NODE_ENV=production ENV NODE_ENV=production
# Entrypoint handles migrations and start
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["/entrypoint.sh"] # Default command can be overridden by compose
CMD ["node", "build/index.js"]

View File

@@ -1,26 +0,0 @@
version: '3.9'
services:
web:
build:
context: .
args:
SKIP_APP_BUILD: "1"
command: sh -lc "\
corepack enable && corepack prepare pnpm@9.12.3 --activate && \
pnpm install && \
pnpm prisma generate && \
pnpm prisma migrate deploy || true && \
pnpm dev --host 0.0.0.0 --port 5173"
volumes:
- ./:/app
- web_node_modules:/app/node_modules
env_file:
- .env
ports:
- "5173:5173"
volumes:
web_node_modules:

View File

@@ -1,8 +1,6 @@
version: '3.9'
services: services:
db: db:
image: postgres:16 image: postgres:latest
environment: environment:
POSTGRES_DB: pypistats POSTGRES_DB: pypistats
POSTGRES_USER: pypistats POSTGRES_USER: pypistats
@@ -12,13 +10,13 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 20 retries: 20
redis: redis:
image: redis:7 image: redis:latest
ports: ports:
- "6379:6379" - "6379:6379"
healthcheck: healthcheck:
@@ -45,7 +43,7 @@ services:
# GOOGLE_APPLICATION_CREDENTIALS_JSON: '{"type":"service_account",...}' # GOOGLE_APPLICATION_CREDENTIALS_JSON: '{"type":"service_account",...}'
ports: ports:
- "3000:3000" - "3000:3000"
command: ["/entrypoint.sh"] command: ["node", "build/index.js"]
volumes: volumes:
pgdata: pgdata:

View File

@@ -1,71 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Wait for Postgres if DATABASE_URL is provided
if [[ -n "${DATABASE_URL:-}" ]]; then
echo "Waiting for database..."
ATTEMPTS=0
until node -e "const { Client } = require('pg'); (async () => { try { const c=new Client({ connectionString: process.env.DATABASE_URL }); await c.connect(); await c.end(); process.exit(0);} catch(e){ process.exit(1);} })()" >/dev/null 2>&1; do
ATTEMPTS=$((ATTEMPTS+1))
if [[ $ATTEMPTS -gt 60 ]]; then
echo "Database did not become ready in time" >&2
exit 1
fi
sleep 1
done
fi
# Run Prisma migrations (safe for prod) with retry
if [[ "${RUN_DB_MIGRATIONS:-1}" == "1" ]]; then
echo "Running prisma migrate deploy..."
ATTEMPTS=0
until pnpm prisma migrate deploy; do
ATTEMPTS=$((ATTEMPTS+1))
if [[ $ATTEMPTS -gt 10 ]]; then
echo "Prisma migrate failed after retries" >&2
exit 1
fi
echo "Retrying migrations in 3s..."
sleep 3
done
fi
# Start the app (SvelteKit Node adapter)
exec node build/index.js
#!/usr/bin/env bash
set -euo pipefail
# Wait for Postgres if DATABASE_URL is provided
if [[ -n "${DATABASE_URL:-}" ]]; then
echo "Waiting for database..."
ATTEMPTS=0
until node -e "const { Client } = require('pg'); (async () => { try { const c=new Client({ connectionString: process.env.DATABASE_URL }); await c.connect(); await c.end(); process.exit(0);} catch(e){ process.exit(1);} })()" >/dev/null 2>&1; do
ATTEMPTS=$((ATTEMPTS+1))
if [[ $ATTEMPTS -gt 60 ]]; then
echo "Database did not become ready in time" >&2
exit 1
fi
sleep 1
done
fi
# Run Prisma migrations (safe for prod) with retry
if [[ "${RUN_DB_MIGRATIONS:-1}" == "1" ]]; then
echo "Running prisma migrate deploy..."
ATTEMPTS=0
until pnpm prisma migrate deploy; do
ATTEMPTS=$((ATTEMPTS+1))
if [[ $ATTEMPTS -gt 10 ]]; then
echo "Prisma migrate failed after retries" >&2
exit 1
fi
echo "Retrying migrations in 3s..."
sleep 3
done
fi
# Start the app (SvelteKit Node adapter)
exec node build/index.js

270
openapi.yaml Normal file
View File

@@ -0,0 +1,270 @@
openapi: 3.0.3
info:
title: PyPI Stats API
version: 1.0.0
description: |
API for querying download statistics for Python packages from PyPI (via BigQuery replication).
Endpoints return JSON or rendered charts. Caching and on-demand freshness are applied.
servers:
- url: https://{host}
variables:
host:
default: localhost:5173
paths:
/api/packages/{package}/recent:
get:
summary: Recent downloads summary (day, week, month)
parameters:
- in: path
name: package
required: true
schema: { type: string }
description: Package name (dots/underscores normalized to hyphens)
- in: query
name: period
schema: { type: string, enum: [day, week, month] }
description: Optional; if omitted returns all three periods
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [recent_downloads] }
data:
type: object
example:
last_day: 123
last_week: 456
last_month: 789
'404': { description: Package not found }
/api/packages/{package}/overall:
get:
summary: Overall downloads time series
parameters:
- in: path
name: package
required: true
schema: { type: string }
- in: query
name: mirrors
schema: { type: string, enum: ['true', 'false'] }
description: Include mirror downloads; omit for both categories
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [overall_downloads] }
data:
type: array
items:
type: object
properties:
date: { type: string, format: date }
category: { type: string, enum: [with_mirrors, without_mirrors] }
downloads: { type: integer }
'404': { description: Package not found }
/api/packages/{package}/python_major:
get:
summary: Python major version downloads time series
parameters:
- in: path
name: package
required: true
schema: { type: string }
- in: query
name: version
schema: { type: string }
description: Optional filter (e.g., '3')
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [python_major_downloads] }
data:
type: array
items:
type: object
properties:
date: { type: string, format: date }
category: { type: string }
downloads: { type: integer }
/api/packages/{package}/python_minor:
get:
summary: Python minor version downloads time series
parameters:
- in: path
name: package
required: true
schema: { type: string }
- in: query
name: version
schema: { type: string }
description: Optional filter (e.g., '3.11')
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [python_minor_downloads] }
data:
type: array
items:
type: object
properties:
date: { type: string, format: date }
category: { type: string }
downloads: { type: integer }
/api/packages/{package}/system:
get:
summary: System OS downloads time series
parameters:
- in: path
name: package
required: true
schema: { type: string }
- in: query
name: os
schema: { type: string, enum: [Windows, Linux, Darwin, other] }
description: Optional filter
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [system_downloads] }
data:
type: array
items:
type: object
properties:
date: { type: string, format: date }
category: { type: string }
downloads: { type: integer }
/api/packages/{package}/installer:
get:
summary: Installer downloads time series
parameters:
- in: path
name: package
required: true
schema: { type: string }
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [installer_downloads] }
data:
type: array
items:
type: object
properties:
date: { type: string, format: date }
category: { type: string }
downloads: { type: integer }
/api/packages/{package}/summary:
get:
summary: At-a-glance totals for a package
parameters:
- in: path
name: package
required: true
schema: { type: string }
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [summary] }
totals:
type: object
properties:
overall: { type: integer }
system:
type: object
additionalProperties: { type: integer }
python_major:
type: object
additionalProperties: { type: integer }
python_minor:
type: object
additionalProperties: { type: integer }
/api/packages/{package}/chart/{type}:
get:
summary: Render a Chart.js image of time series
parameters:
- in: path
name: package
required: true
schema: { type: string }
- in: path
name: type
required: true
schema: { type: string, enum: [overall, python_major, python_minor, system] }
- in: query
name: chart
schema: { type: string, enum: [line, bar] }
description: Chart type to render
- in: query
name: mirrors
schema: { type: string, enum: ['true', 'false'] }
description: Only for type=overall
- in: query
name: version
schema: { type: string }
description: Only for type=python_major/python_minor (e.g., 3 or 3.11)
- in: query
name: os
schema: { type: string, enum: [Windows, Linux, Darwin, other] }
description: Only for type=system
responses:
'200':
description: PNG image
content:
image/png:
schema:
type: string
format: binary
'400': { description: Bad request }
'404': { description: Package not found }
components:
securitySchemes: {}

View File

@@ -4,9 +4,9 @@
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "svelte-kit build", "build": "svelte-kit build",
"start": "node build/index.js", "start": "node build/index.js",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -20,10 +20,13 @@
"dependencies": { "dependencies": {
"@google-cloud/bigquery": "^8.1.1", "@google-cloud/bigquery": "^8.1.1",
"@prisma/client": "^6.13.0", "@prisma/client": "^6.13.0",
"@sveltejs/adapter-node": "^5.2.8", "@sveltejs/adapter-node": "^5.2.8",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"redis": "^5.7.0" "redis": "^5.7.0",
"chart.js": "^4.4.4",
"chartjs-node-canvas": "^5.0.0",
"canvas": "^3.1.2"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^6.0.1", "@sveltejs/adapter-auto": "^6.0.1",
@@ -45,7 +48,8 @@
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"esbuild" "esbuild",
"canvas"
] ]
} }
} }

231
pnpm-lock.yaml generated
View File

@@ -20,6 +20,15 @@ importers:
'@types/node-cron': '@types/node-cron':
specifier: ^3.0.11 specifier: ^3.0.11
version: 3.0.11 version: 3.0.11
canvas:
specifier: ^3.1.2
version: 3.1.2
chart.js:
specifier: ^4.4.4
version: 4.5.0
chartjs-node-canvas:
specifier: ^5.0.0
version: 5.0.0(chart.js@4.5.0)
node-cron: node-cron:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1 version: 4.2.1
@@ -291,6 +300,9 @@ packages:
'@jridgewell/trace-mapping@0.3.29': '@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -708,9 +720,15 @@ packages:
bignumber.js@9.3.1: bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
buffer-equal-constant-time@1.0.1: buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
c12@3.1.0: c12@3.1.0:
resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==}
peerDependencies: peerDependencies:
@@ -723,10 +741,26 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
canvas@3.1.2:
resolution: {integrity: sha512-Z/tzFAcBzoCvJlOSlCnoekh1Gu8YMn0J51+UAuXJAbW1Z6I9l2mZgdD7738MepoeeIcUdDtbMnOg6cC7GJxy/g==}
engines: {node: ^18.12.0 || >= 20.9.0}
chart.js@4.5.0:
resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==}
engines: {pnpm: '>=8'}
chartjs-node-canvas@5.0.0:
resolution: {integrity: sha512-+Lc5phRWjb+UxAIiQpKgvOaG6Mw276YQx2jl2BrxoUtI3A4RYTZuGM5Dq+s4ReYmCY42WEPSR6viF3lDSTxpvw==}
peerDependencies:
chart.js: ^4.4.8
chokidar@4.0.3: chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'} engines: {node: '>= 14.16.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
chownr@3.0.0: chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -778,6 +812,14 @@ packages:
supports-color: supports-color:
optional: true optional: true
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
deepmerge-ts@7.1.5: deepmerge-ts@7.1.5:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -857,6 +899,10 @@ packages:
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
exsolve@1.0.7: exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
@@ -891,6 +937,9 @@ packages:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -919,6 +968,9 @@ packages:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
hasBin: true hasBin: true
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
google-auth-library@10.2.1: google-auth-library@10.2.1:
resolution: {integrity: sha512-HMxFl2NfeHYnaL1HoRIN1XgorKS+6CDaM+z9LSSN+i/nKDDL4KFFEWogMXu7jV4HZQy2MsxpY+wA5XIf3w410A==} resolution: {integrity: sha512-HMxFl2NfeHYnaL1HoRIN1XgorKS+6CDaM+z9LSSN+i/nKDDL4KFFEWogMXu7jV4HZQy2MsxpY+wA5XIf3w410A==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -969,6 +1021,9 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
index-to-position@1.1.0: index-to-position@1.1.0:
resolution: {integrity: sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==} resolution: {integrity: sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -976,6 +1031,9 @@ packages:
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
is-core-module@2.16.1: is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1108,10 +1166,17 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
mini-svg-data-uri@1.4.4: mini-svg-data-uri@1.4.4:
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
hasBin: true hasBin: true
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.2: minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
@@ -1120,6 +1185,9 @@ packages:
resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mkdirp@3.0.1: mkdirp@3.0.1:
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1141,6 +1209,16 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
node-abi@3.75.0:
resolution: {integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==}
engines: {node: '>=10'}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-cron@4.2.1: node-cron@4.2.1:
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@@ -1203,6 +1281,11 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
hasBin: true
prettier-plugin-svelte@3.4.0: prettier-plugin-svelte@3.4.0:
resolution: {integrity: sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==} resolution: {integrity: sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==}
peerDependencies: peerDependencies:
@@ -1292,12 +1375,19 @@ packages:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'} engines: {node: '>=6'}
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
pure-rand@6.1.0: pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
rc9@2.1.2: rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
read-package-up@11.0.0: read-package-up@11.0.0:
resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1347,6 +1437,12 @@ packages:
set-cookie-parser@2.7.1: set-cookie-parser@2.7.1:
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
sirv@3.0.1: sirv@3.0.1:
resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1376,6 +1472,10 @@ packages:
string_decoder@1.3.0: string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
stubs@3.0.0: stubs@3.0.0:
resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==}
@@ -1402,6 +1502,13 @@ packages:
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
engines: {node: '>=6'} engines: {node: '>=6'}
tar-fs@2.1.3:
resolution: {integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar@7.4.3: tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1421,6 +1528,12 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
type-fest@4.41.0: type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'} engines: {node: '>=16'}
@@ -1672,6 +1785,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4 '@jridgewell/sourcemap-codec': 1.5.4
'@kurkle/color@0.3.4': {}
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@prisma/client@6.13.0(prisma@6.13.0(typescript@5.9.2))(typescript@5.9.2)': '@prisma/client@6.13.0(prisma@6.13.0(typescript@5.9.2))(typescript@5.9.2)':
@@ -2027,8 +2142,19 @@ snapshots:
bignumber.js@9.3.1: {} bignumber.js@9.3.1: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
buffer-equal-constant-time@1.0.1: {} buffer-equal-constant-time@1.0.1: {}
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
c12@3.1.0: c12@3.1.0:
dependencies: dependencies:
chokidar: 4.0.3 chokidar: 4.0.3
@@ -2049,10 +2175,27 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
function-bind: 1.1.2 function-bind: 1.1.2
canvas@3.1.2:
dependencies:
node-addon-api: 7.1.1
prebuild-install: 7.1.3
chart.js@4.5.0:
dependencies:
'@kurkle/color': 0.3.4
chartjs-node-canvas@5.0.0(chart.js@4.5.0):
dependencies:
canvas: 3.1.2
chart.js: 4.5.0
tslib: 2.8.1
chokidar@4.0.3: chokidar@4.0.3:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
chownr@1.1.4: {}
chownr@3.0.0: {} chownr@3.0.0: {}
citty@0.1.6: citty@0.1.6:
@@ -2083,6 +2226,12 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
deep-extend@0.6.0: {}
deepmerge-ts@7.1.5: {} deepmerge-ts@7.1.5: {}
deepmerge@4.3.1: {} deepmerge@4.3.1: {}
@@ -2182,6 +2331,8 @@ snapshots:
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
expand-template@2.0.3: {}
exsolve@1.0.7: {} exsolve@1.0.7: {}
extend@3.0.2: {} extend@3.0.2: {}
@@ -2214,6 +2365,8 @@ snapshots:
dependencies: dependencies:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
fs-constants@1.0.0: {}
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -2262,6 +2415,8 @@ snapshots:
nypm: 0.6.1 nypm: 0.6.1
pathe: 2.0.3 pathe: 2.0.3
github-from-package@0.0.0: {}
google-auth-library@10.2.1: google-auth-library@10.2.1:
dependencies: dependencies:
base64-js: 1.5.1 base64-js: 1.5.1
@@ -2325,10 +2480,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
ieee754@1.2.1: {}
index-to-position@1.1.0: {} index-to-position@1.1.0: {}
inherits@2.0.4: {} inherits@2.0.4: {}
ini@1.3.8: {}
is-core-module@2.16.1: is-core-module@2.16.1:
dependencies: dependencies:
hasown: 2.0.2 hasown: 2.0.2
@@ -2441,14 +2600,20 @@ snapshots:
dependencies: dependencies:
mime-db: 1.52.0 mime-db: 1.52.0
mimic-response@3.1.0: {}
mini-svg-data-uri@1.4.4: {} mini-svg-data-uri@1.4.4: {}
minimist@1.2.8: {}
minipass@7.1.2: {} minipass@7.1.2: {}
minizlib@3.0.2: minizlib@3.0.2:
dependencies: dependencies:
minipass: 7.1.2 minipass: 7.1.2
mkdirp-classic@0.5.3: {}
mkdirp@3.0.1: {} mkdirp@3.0.1: {}
mri@1.2.0: {} mri@1.2.0: {}
@@ -2459,6 +2624,14 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
napi-build-utils@2.0.0: {}
node-abi@3.75.0:
dependencies:
semver: 7.7.2
node-addon-api@7.1.1: {}
node-cron@4.2.1: {} node-cron@4.2.1: {}
node-domexception@1.0.0: {} node-domexception@1.0.0: {}
@@ -2524,6 +2697,21 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.0.4
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.75.0
pump: 3.0.3
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.3
tunnel-agent: 0.6.0
prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.37.3): prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.37.3):
dependencies: dependencies:
prettier: 3.6.2 prettier: 3.6.2
@@ -2550,6 +2738,11 @@ snapshots:
prismjs@1.30.0: {} prismjs@1.30.0: {}
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
pure-rand@6.1.0: {} pure-rand@6.1.0: {}
rc9@2.1.2: rc9@2.1.2:
@@ -2557,6 +2750,13 @@ snapshots:
defu: 6.1.4 defu: 6.1.4
destr: 2.0.5 destr: 2.0.5
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
read-package-up@11.0.0: read-package-up@11.0.0:
dependencies: dependencies:
find-up-simple: 1.0.1 find-up-simple: 1.0.1
@@ -2637,6 +2837,14 @@ snapshots:
set-cookie-parser@2.7.1: {} set-cookie-parser@2.7.1: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
sirv@3.0.1: sirv@3.0.1:
dependencies: dependencies:
'@polka/url': 1.0.0-next.29 '@polka/url': 1.0.0-next.29
@@ -2669,6 +2877,8 @@ snapshots:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
strip-json-comments@2.0.1: {}
stubs@3.0.0: {} stubs@3.0.0: {}
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
@@ -2706,6 +2916,21 @@ snapshots:
tapable@2.2.2: {} tapable@2.2.2: {}
tar-fs@2.1.3:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.3
tar-stream: 2.2.0
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
tar@7.4.3: tar@7.4.3:
dependencies: dependencies:
'@isaacs/fs-minipass': 4.0.1 '@isaacs/fs-minipass': 4.0.1
@@ -2733,6 +2958,12 @@ snapshots:
totalist@3.0.1: {} totalist@3.0.1: {}
tslib@2.8.1: {}
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
type-fest@4.41.0: {} type-fest@4.41.0: {}
typescript@5.9.2: {} typescript@5.9.2: {}

View File

@@ -21,28 +21,38 @@ CREATE TABLE "public"."overall" (
CREATE TABLE "public"."python_major" ( CREATE TABLE "public"."python_major" (
"date" DATE NOT NULL, "date" DATE NOT NULL,
"package" TEXT NOT NULL, "package" TEXT NOT NULL,
"category" TEXT, "category" TEXT NOT NULL,
"downloads" INTEGER NOT NULL, "downloads" INTEGER NOT NULL,
CONSTRAINT "python_major_pkey" PRIMARY KEY ("date","package") CONSTRAINT "python_major_pkey" PRIMARY KEY ("date","package","category")
); );
-- CreateTable -- CreateTable
CREATE TABLE "public"."python_minor" ( CREATE TABLE "public"."python_minor" (
"date" DATE NOT NULL, "date" DATE NOT NULL,
"package" TEXT NOT NULL, "package" TEXT NOT NULL,
"category" TEXT, "category" TEXT NOT NULL,
"downloads" INTEGER NOT NULL, "downloads" INTEGER NOT NULL,
CONSTRAINT "python_minor_pkey" PRIMARY KEY ("date","package") CONSTRAINT "python_minor_pkey" PRIMARY KEY ("date","package","category")
); );
-- CreateTable -- CreateTable
CREATE TABLE "public"."system" ( CREATE TABLE "public"."system" (
"date" DATE NOT NULL, "date" DATE NOT NULL,
"package" TEXT NOT NULL, "package" TEXT NOT NULL,
"category" TEXT, "category" TEXT NOT NULL,
"downloads" INTEGER NOT NULL, "downloads" INTEGER NOT NULL,
CONSTRAINT "system_pkey" PRIMARY KEY ("date","package") CONSTRAINT "system_pkey" PRIMARY KEY ("date","package","category")
);
-- CreateTable
CREATE TABLE "public"."installer" (
"date" DATE NOT NULL,
"package" TEXT NOT NULL,
"category" TEXT NOT NULL,
"downloads" INTEGER NOT NULL,
CONSTRAINT "installer_pkey" PRIMARY KEY ("date","package","category")
); );

View File

@@ -1,4 +1,4 @@
import { closeRedisClient, forceDisconnectRedis } from '$lib/redis.js'; import { closeRedisClient, forceDisconnectRedis, getRedisClient } from '$lib/redis.js';
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
// Minimal server hooks without cron // Minimal server hooks without cron
@@ -30,6 +30,60 @@ if (typeof process !== 'undefined') {
}); });
} }
// Sliding window rate limit parameters
const WINDOW_SECONDS = 60; // 1 minute
const MAX_REQUESTS = 300; // per id per window
async function consumeSlidingWindow(key: string, points: number, windowSeconds: number) {
const client = getRedisClient();
if (!client) return { allowed: true, remaining: MAX_REQUESTS };
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
const listKey = `rl:sw:${key}`;
// Remove old entries and push current
const lua = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local windowStart = tonumber(ARGV[2])
local points = tonumber(ARGV[3])
local limit = tonumber(ARGV[4])
-- Trim old timestamps
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
-- Add current request timestamps (as single timestamp repeated 'points' times)
for i=1,points do
redis.call('ZADD', key, now, now .. '-' .. i)
end
local count = redis.call('ZCARD', key)
-- Set expiry just beyond window
redis.call('EXPIRE', key, windowStart + (60*60*24) == 0 and 60 or math.floor((now - windowStart)/1000) + 5)
return count
`;
const count = (await client.eval(lua, {
keys: [listKey],
arguments: [String(now), String(windowStart), String(points), String(MAX_REQUESTS)]
})) as number;
return { allowed: count <= MAX_REQUESTS, remaining: Math.max(0, MAX_REQUESTS - count) };
}
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
// Only apply to API routes
if (event.url.pathname.startsWith('/api/')) {
// Identify client by IP (or forwarded-for)
const ip = (event.getClientAddress?.() || event.request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || 'unknown';
const key = `ip:${ip}`;
const result = await consumeSlidingWindow(key, 1, WINDOW_SECONDS);
if (!result.allowed) {
const reset = WINDOW_SECONDS; // approximate
return new Response('Too Many Requests', {
status: 429,
headers: {
'Retry-After': String(reset),
'RateLimit-Policy': `${MAX_REQUESTS};w=${WINDOW_SECONDS}`,
'RateLimit-Remaining': '0'
}
});
}
}
return resolve(event); return resolve(event);
}; };

View File

@@ -1,8 +1,21 @@
import { prisma } from './prisma.js'; import { prisma } from './prisma.js';
import { RECENT_CATEGORIES } from './database.js'; import { RECENT_CATEGORIES } from './database.js';
import { CacheManager } from './redis.js'; import { CacheManager } from './redis.js';
import { DataProcessor } from './data-processor.js';
const cache = new CacheManager(); const cache = new CacheManager();
let processor: DataProcessor | null = null;
function getProcessor() {
if (!processor) processor = new DataProcessor();
return processor;
}
async function ensurePackageFreshnessFor(packageName: string) {
try {
await getProcessor().ensurePackageFreshness(packageName);
} catch (error) {
console.error('Failed to ensure package freshness:', packageName, error);
}
}
export type Results = { export type Results = {
date: string; date: string;
@@ -13,11 +26,10 @@ export type Results = {
export async function getRecentDownloads(packageName: string, category?: string): Promise<Results[]> { export async function getRecentDownloads(packageName: string, category?: string): Promise<Results[]> {
const cacheKey = CacheManager.getRecentStatsKey(packageName); const cacheKey = CacheManager.getRecentStatsKey(packageName);
// Try to get from cache first // Ensure DB has fresh data for this package before computing recent
const cached = await cache.get<Results[]>(cacheKey); if (!category) {
if (cached && !category) { await ensurePackageFreshnessFor(packageName);
return cached; }
}
if (category && RECENT_CATEGORIES.includes(category)) { if (category && RECENT_CATEGORIES.includes(category)) {
// Compute recent from overall without mirrors // Compute recent from overall without mirrors
@@ -44,8 +56,12 @@ export async function getRecentDownloads(packageName: string, category?: string)
const month: Results[] = await getRecentDownloads(packageName, 'month'); const month: Results[] = await getRecentDownloads(packageName, 'month');
const result: Results[] = [...day, ...week, ...month]; const result: Results[] = [...day, ...week, ...month];
// Cache the result for 1 hour // Cache only if non-empty; otherwise clear any stale empty cache
await cache.set(cacheKey, result, 3600); if (result.length > 0) {
await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result; return result;
} }
@@ -66,11 +82,8 @@ function getRecentBounds(category: string) {
export async function getOverallDownloads(packageName: string, mirrors?: string) { export async function getOverallDownloads(packageName: string, mirrors?: string) {
const cacheKey = CacheManager.getPackageKey(packageName, `overall_${mirrors || 'all'}`); const cacheKey = CacheManager.getPackageKey(packageName, `overall_${mirrors || 'all'}`);
// Try to get from cache first // Always ensure DB freshness first to avoid returning stale cache
const cached = await cache.get<Results[]>(cacheKey); await ensurePackageFreshnessFor(packageName);
if (cached) {
return cached;
}
const whereClause: any = { const whereClause: any = {
package: packageName package: packageName
@@ -89,8 +102,12 @@ export async function getOverallDownloads(packageName: string, mirrors?: string)
} }
}); });
// Cache the result for 1 hour // Cache only if non-empty; otherwise clear any stale empty cache
await cache.set(cacheKey, result, 3600); if (result.length > 0) {
await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result; return result;
} }
@@ -98,11 +115,8 @@ export async function getOverallDownloads(packageName: string, mirrors?: string)
export async function getPythonMajorDownloads(packageName: string, version?: string) { export async function getPythonMajorDownloads(packageName: string, version?: string) {
const cacheKey = CacheManager.getPackageKey(packageName, `python_major_${version || 'all'}`); const cacheKey = CacheManager.getPackageKey(packageName, `python_major_${version || 'all'}`);
// Try to get from cache first // Ensure DB freshness first
const cached = await cache.get<Results[]>(cacheKey); await ensurePackageFreshnessFor(packageName);
if (cached) {
return cached;
}
const whereClause: any = { const whereClause: any = {
package: packageName package: packageName
@@ -119,8 +133,11 @@ export async function getPythonMajorDownloads(packageName: string, version?: str
} }
}); });
// Cache the result for 1 hour if (result.length > 0) {
await cache.set(cacheKey, result, 3600); await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result; return result;
} }
@@ -128,11 +145,8 @@ export async function getPythonMajorDownloads(packageName: string, version?: str
export async function getPythonMinorDownloads(packageName: string, version?: string) { export async function getPythonMinorDownloads(packageName: string, version?: string) {
const cacheKey = CacheManager.getPackageKey(packageName, `python_minor_${version || 'all'}`); const cacheKey = CacheManager.getPackageKey(packageName, `python_minor_${version || 'all'}`);
// Try to get from cache first // Ensure DB freshness first
const cached = await cache.get<Results[]>(cacheKey); await ensurePackageFreshnessFor(packageName);
if (cached) {
return cached;
}
const whereClause: any = { const whereClause: any = {
package: packageName package: packageName
@@ -149,8 +163,11 @@ export async function getPythonMinorDownloads(packageName: string, version?: str
} }
}); });
// Cache the result for 1 hour if (result.length > 0) {
await cache.set(cacheKey, result, 3600); await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result; return result;
} }
@@ -158,11 +175,8 @@ export async function getPythonMinorDownloads(packageName: string, version?: str
export async function getSystemDownloads(packageName: string, os?: string) { export async function getSystemDownloads(packageName: string, os?: string) {
const cacheKey = CacheManager.getPackageKey(packageName, `system_${os || 'all'}`); const cacheKey = CacheManager.getPackageKey(packageName, `system_${os || 'all'}`);
// Try to get from cache first // Ensure DB freshness first
const cached = await cache.get<Results[]>(cacheKey); await ensurePackageFreshnessFor(packageName);
if (cached) {
return cached;
}
const whereClause: any = { const whereClause: any = {
package: packageName package: packageName
@@ -179,68 +193,242 @@ export async function getSystemDownloads(packageName: string, os?: string) {
} }
}); });
// Cache the result for 1 hour if (result.length > 0) {
await cache.set(cacheKey, result, 3600); await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result; return result;
} }
export async function getInstallerDownloads(packageName: string, installer?: string) {
const cacheKey = CacheManager.getPackageKey(packageName, `installer_${installer || 'all'}`);
// Ensure DB freshness first
await ensurePackageFreshnessFor(packageName);
const whereClause: any = { package: packageName };
if (installer) whereClause.category = installer;
const result = await (prisma as any).installerDownloadCount.findMany({
where: whereClause,
orderBy: { date: 'asc' }
});
if (result.length > 0) {
await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result as Array<{ date: Date; package: string; category: string; downloads: number }>;
}
export async function getVersionDownloads(packageName: string, version?: string) {
const cacheKey = CacheManager.getPackageKey(packageName, `version_${version || 'all'}`);
// Ensure DB freshness first
await ensurePackageFreshnessFor(packageName);
const whereClause: any = { package: packageName };
if (version) whereClause.category = version;
try {
const model = (prisma as any).versionDownloadCount;
if (!model) return [] as Array<{ date: Date; package: string; category: string; downloads: number }>;
const result = await model.findMany({
where: whereClause,
orderBy: { date: 'asc' }
});
if (result.length > 0) {
await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result as Array<{ date: Date; package: string; category: string; downloads: number }>;
} catch (error) {
console.error('getVersionDownloads failed:', error);
return [] as Array<{ date: Date; package: string; category: string; downloads: number }>;
}
}
export async function searchPackages(searchTerm: string) { export async function searchPackages(searchTerm: string) {
const cacheKey = CacheManager.getSearchKey(searchTerm); const query = (searchTerm || '').trim();
if (!query) return [] as string[];
const cacheKey = CacheManager.getSearchKey(query);
// Try to get from cache first // Try to get from cache first
const cached = await cache.get<string[]>(cacheKey); const cached = await cache.get<string[]>(cacheKey);
if (cached) { if (cached) {
return cached; return cached;
} }
const results = await prisma.recentDownloadCount.findMany({ // Use PyPI Simple API (PEP 691 JSON) to fetch the package index, cache it,
where: { // then do a local prefix filter for suggestions.
package: { const controller = new AbortController();
startsWith: searchTerm const timeout = setTimeout(() => controller.abort(), 8000);
}, const indexKey = CacheManager.getSearchKey('__simple_index__');
category: 'month' try {
}, // Try index from cache first
select: { let allPackages = await cache.get<string[]>(indexKey);
package: true
},
distinct: ['package'],
orderBy: {
package: 'asc'
},
take: 20
});
const packages = results.map(result => result.package);
// Cache the result for 30 minutes (search results change less frequently) if (!allPackages) {
await cache.set(cacheKey, packages, 1800); const indexResponse = await fetch('https://pypi.org/simple/', {
method: 'GET',
return packages; headers: {
'Accept': 'application/vnd.pypi.simple.v1+json',
'User-Agent': 'pypistats.app (server-side)'
},
signal: controller.signal
});
if (!indexResponse.ok) {
console.error('PyPI Simple index error:', indexResponse.status, indexResponse.statusText);
} else {
const indexJson = (await indexResponse.json()) as { projects?: Array<{ name: string; url: string }>; };
allPackages = (indexJson.projects || []).map((p) => p.name);
if (allPackages.length > 0) {
// Cache the full index for 6 hours
await cache.set(indexKey, allPackages, 6 * 60 * 60);
}
}
}
const q = query.toLowerCase();
let matches: string[] = [];
if (Array.isArray(allPackages) && allPackages.length > 0) {
matches = allPackages
.filter((name) => name.toLowerCase().startsWith(q))
.slice(0, 20);
}
// Fallback: if no matches from the index, try exact project existence via JSON API
if (matches.length === 0) {
const projectResponse = await fetch(`https://pypi.org/pypi/${encodeURIComponent(query)}/json`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'pypistats.app (server-side)'
},
signal: controller.signal
});
if (projectResponse.ok) {
matches = [query];
}
}
// Cache per-query matches for 30 minutes
await cache.set(cacheKey, matches, 1800);
return matches;
} catch (error) {
if ((error as any)?.name === 'AbortError') {
console.error('PyPI Simple API request timed out');
} else {
console.error('PyPI Simple/API request failed:', error);
}
return [] as string[];
} finally {
clearTimeout(timeout);
}
} }
export async function getPackageCount() { export async function getPackageCount() {
const cacheKey = CacheManager.getPackageCountKey(); try {
// First try recent monthly snapshot as authoritative
// Try to get from cache first const recent = await prisma.recentDownloadCount.findMany({
const cached = await cache.get<number>(cacheKey); where: { category: 'month' },
if (cached !== null) { distinct: ['package'],
return cached; select: { package: true }
} });
const result = await prisma.recentDownloadCount.groupBy({ const distinct = new Set<string>(recent.map((r) => r.package));
const count = distinct.size;
return count;
} catch (error) {
console.error('getPackageCount failed:', error);
return undefined
}
}
export async function getPopularPackages(limit = 10, days = 30): Promise<Array<{ package: string; downloads: number }>> {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
// Prefer 'without_mirrors' as the canonical signal
const grouped = await prisma.overallDownloadCount.groupBy({
by: ['package'], by: ['package'],
where: { where: {
category: 'month' category: 'without_mirrors',
} date: { gte: cutoff }
},
_sum: { downloads: true },
orderBy: { _sum: { downloads: 'desc' } },
take: limit
}); });
return grouped.map((g) => ({ package: g.package, downloads: Number(g._sum.downloads || 0) }));
const count = result.length; }
// Cache the result for 1 hour export type PackageMetadata = {
await cache.set(cacheKey, count, 3600); name: string;
version: string | null;
return count; summary: string | null;
homePage: string | null;
projectUrls: Record<string, string> | null;
pypiUrl: string;
latestReleaseDate: string | null;
};
export async function getPackageMetadata(packageName: string): Promise<PackageMetadata> {
const url = `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`;
try {
const res = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'pypistats.app (server-side)'
}
});
if (!res.ok) {
return {
name: packageName,
version: null,
summary: null,
homePage: null,
projectUrls: null,
pypiUrl: `https://pypi.org/project/${packageName}/`,
latestReleaseDate: null
};
}
const json = await res.json();
const info = json?.info || {};
const version = info?.version ?? null;
// Determine latest upload time for the current version
let latestReleaseDate: string | null = null;
try {
const releases = json?.releases || {};
const files = Array.isArray(releases?.[version]) ? releases[version] : [];
const latest = files.reduce((max: string | null, f: any) => {
const t = f?.upload_time_iso_8601 || f?.upload_time || null;
if (!t) return max;
return !max || new Date(t).getTime() > new Date(max).getTime() ? t : max;
}, null as string | null);
latestReleaseDate = latest ? new Date(latest).toISOString().split('T')[0] : null;
} catch {}
return {
name: packageName,
version,
summary: info?.summary ?? null,
homePage: info?.home_page ?? null,
projectUrls: (info?.project_urls as Record<string, string> | undefined) ?? null,
pypiUrl: `https://pypi.org/project/${packageName}/`,
latestReleaseDate
};
} catch (error) {
console.error('getPackageMetadata error:', error);
return {
name: packageName,
version: null,
summary: null,
homePage: null,
projectUrls: null,
pypiUrl: `https://pypi.org/project/${packageName}/`,
latestReleaseDate: null
};
}
} }
// Cache invalidation functions // Cache invalidation functions

View File

@@ -313,6 +313,7 @@ export class DataProcessor {
WITH dls AS ( WITH dls AS (
SELECT SELECT
file.project AS package, file.project AS package,
file.version AS file_version,
details.installer.name AS installer, details.installer.name AS installer,
details.python AS python_version, details.python AS python_version,
details.system.name AS system details.system.name AS system
@@ -374,6 +375,17 @@ export class DataProcessor {
FROM dls FROM dls
WHERE installer NOT IN (${MIRRORS.map(m => `'${m}'`).join(', ')}) WHERE installer NOT IN (${MIRRORS.map(m => `'${m}'`).join(', ')})
GROUP BY package, category GROUP BY package, category
UNION ALL
SELECT
package,
'version' AS category_label,
COALESCE(file_version, 'unknown') AS category,
COUNT(*) AS downloads
FROM dls
WHERE installer NOT IN (${MIRRORS.map(m => `'${m}'`).join(', ')})
GROUP BY package, category
`; `;
} }
@@ -446,6 +458,7 @@ export class DataProcessor {
SELECT SELECT
DATE(timestamp) AS date, DATE(timestamp) AS date,
file.project AS package, file.project AS package,
file.version AS file_version,
details.installer.name AS installer, details.installer.name AS installer,
details.python AS python_version, details.python AS python_version,
details.system.name AS system details.system.name AS system
@@ -518,6 +531,17 @@ export class DataProcessor {
COUNT(*) AS downloads COUNT(*) AS downloads
FROM dls FROM dls
GROUP BY date, package, category GROUP BY date, package, category
UNION ALL
SELECT
date,
package,
'version' AS category_label,
COALESCE(file_version, 'unknown') AS category,
COUNT(*) AS downloads
FROM dls
GROUP BY date, package, category
`; `;
} }
@@ -550,15 +574,68 @@ export class DataProcessor {
where: { date: dateObj } where: { date: dateObj }
}); });
break; break;
case 'version':
if ((tx as any).versionDownloadCount) {
await (tx as any).versionDownloadCount.deleteMany({ where: { date: dateObj } });
}
break;
} }
} }
private async insertRecords(table: string, records: any[], tx: Prisma.TransactionClient): Promise<void> { private async insertRecords(table: string, records: any[], tx: Prisma.TransactionClient): Promise<void> {
const normalizeDate = (value: any): Date => {
if (value instanceof Date) return value;
if (typeof value === 'string') {
const trimmed = value.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const d = new Date(`${trimmed}T00:00:00Z`);
if (!isNaN(d.getTime())) return d;
}
const d = new Date(trimmed);
if (!isNaN(d.getTime())) return d;
}
if (value && typeof value === 'object') {
// BigQuery DATE often arrives as { value: 'YYYY-MM-DD' }
if (typeof (value as any).value === 'string') {
return normalizeDate((value as any).value);
}
// Some drivers return { year, month, day }
const maybeY = (value as any).year;
const maybeM = (value as any).month;
const maybeD = (value as any).day;
if (
typeof maybeY === 'number' &&
typeof maybeM === 'number' &&
typeof maybeD === 'number'
) {
const mm = String(maybeM).padStart(2, '0');
const dd = String(maybeD).padStart(2, '0');
return normalizeDate(`${maybeY}-${mm}-${dd}`);
}
// Timestamp-like with toDate()
if (typeof (value as any).toDate === 'function') {
const d = (value as any).toDate();
if (d instanceof Date && !isNaN(d.getTime())) return d;
}
// Timestamp-like with seconds/nanos
if (
typeof (value as any).seconds === 'number' ||
typeof (value as any).nanos === 'number'
) {
const seconds = Number((value as any).seconds || 0);
const nanos = Number((value as any).nanos || 0);
const d = new Date(seconds * 1000 + Math.floor(nanos / 1e6));
if (!isNaN(d.getTime())) return d;
}
}
throw new Error(`Invalid date value: ${value}`);
};
switch (table) { switch (table) {
case 'overall': case 'overall':
await tx.overallDownloadCount.createMany({ await tx.overallDownloadCount.createMany({
data: records.map(r => ({ data: records.map(r => ({
date: new Date(r.date), date: normalizeDate(r.date),
package: r.package, package: r.package,
category: r.category ?? 'unknown', category: r.category ?? 'unknown',
downloads: r.downloads downloads: r.downloads
@@ -568,7 +645,7 @@ export class DataProcessor {
case 'python_major': case 'python_major':
await tx.pythonMajorDownloadCount.createMany({ await tx.pythonMajorDownloadCount.createMany({
data: records.map(r => ({ data: records.map(r => ({
date: new Date(r.date), date: normalizeDate(r.date),
package: r.package, package: r.package,
category: r.category ?? 'unknown', category: r.category ?? 'unknown',
downloads: r.downloads downloads: r.downloads
@@ -578,7 +655,7 @@ export class DataProcessor {
case 'python_minor': case 'python_minor':
await tx.pythonMinorDownloadCount.createMany({ await tx.pythonMinorDownloadCount.createMany({
data: records.map(r => ({ data: records.map(r => ({
date: new Date(r.date), date: normalizeDate(r.date),
package: r.package, package: r.package,
category: r.category ?? 'unknown', category: r.category ?? 'unknown',
downloads: r.downloads downloads: r.downloads
@@ -588,7 +665,7 @@ export class DataProcessor {
case 'system': case 'system':
await tx.systemDownloadCount.createMany({ await tx.systemDownloadCount.createMany({
data: records.map(r => ({ data: records.map(r => ({
date: new Date(r.date), date: normalizeDate(r.date),
package: r.package, package: r.package,
category: r.category ?? 'other', category: r.category ?? 'other',
downloads: r.downloads downloads: r.downloads
@@ -598,13 +675,25 @@ export class DataProcessor {
case 'installer': case 'installer':
await (tx as any).installerDownloadCount.createMany({ await (tx as any).installerDownloadCount.createMany({
data: records.map(r => ({ data: records.map(r => ({
date: new Date(r.date), date: normalizeDate(r.date),
package: r.package, package: r.package,
category: r.category ?? 'unknown', category: r.category ?? 'unknown',
downloads: r.downloads downloads: r.downloads
})) }))
}); });
break; break;
case 'version':
if ((tx as any).versionDownloadCount) {
await (tx as any).versionDownloadCount.createMany({
data: records.map(r => ({
date: normalizeDate(r.date),
package: r.package,
category: r.category ?? 'unknown',
downloads: r.downloads
}))
});
}
break;
} }
} }
@@ -735,6 +824,11 @@ export class DataProcessor {
where: { date: { lt: purgeDate } } where: { date: { lt: purgeDate } }
}); });
return systemResult.count; return systemResult.count;
case 'version':
const versionResult = await (prisma as any).versionDownloadCount.deleteMany({
where: { date: { lt: purgeDate } }
});
return versionResult.count;
default: default:
return 0; return 0;
} }

View File

@@ -26,9 +26,7 @@
<a href="/api" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> <a href="/api" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
API API
</a> </a>
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Admin
</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,15 +1,23 @@
import { getPackageCount } from '$lib/api.js'; import { getPackageCount, getPopularPackages } from '$lib/api.js';
import type { PageServerLoad } from './$types';
export const load = async () => { export const load: PageServerLoad = async () => {
try { try {
const packageCount = getPackageCount(); // Count distinct packages that have any saved data in DB (recent/month as proxy)
const [packageCount, popular] = await Promise.all([
getPackageCount(),
getPopularPackages(10, 30)
]);
return { return {
packageCount packageCount,
popular
}; };
} catch (error) { } catch (error) {
console.error('Error loading page data:', error); console.error('Error loading page data:', error);
return { return {
packageCount: 0 packageCount: 0,
popular: []
}; };
} }
}; };

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import type { PageData } from './$types'; import type { PageData } from './$types';
const { data } = $props<{ data: PageData }>(); const { data } = $props<{ data: PageData }>();
@@ -22,8 +21,8 @@
</p> </p>
<!-- Search Form --> <!-- Search Form -->
<div class="max-w-md mx-auto"> <div class="max-w-md mx-auto">
<form method="POST" action="/search" use:enhance class="flex gap-2"> <form method="GET" action="/search" class="flex gap-2">
<input <input
type="text" type="text"
name="q" name="q"
@@ -41,20 +40,29 @@
</form> </form>
</div> </div>
{#await data.packageCount then packageCount} <div class="mt-8 text-sm text-gray-500">
<div class="mt-8 text-sm text-gray-500"> Tracking {data.packageCount ? data.packageCount.toLocaleString() : 'tons of'} packages
Tracking {packageCount?.toLocaleString()} packages </div>
</div>
{/await}
</div> </div>
<!-- Quick Links --> <!-- Quick Links -->
<div class="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8"> <div class="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="bg-white p-6 rounded-lg shadow-sm border"> <div class="bg-white p-6 rounded-lg shadow-sm border">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Popular Packages</h3> <h3 class="text-lg font-semibold text-gray-900 mb-2">Popular Packages (last 30 days)</h3>
<p class="text-gray-600 mb-4">Check download stats for popular Python packages</p> <p class="text-gray-600 mb-4">Top projects by downloads (without mirrors)</p>
<a href="/search/numpy" class="text-blue-600 hover:text-blue-800 font-medium">View Examples →</a> {#if data.popular && data.popular.length > 0}
</div> <ul class="divide-y divide-gray-200">
{#each data.popular as row}
<li class="py-2 flex items-center justify-between">
<a class="text-blue-600 hover:text-blue-800 font-medium" href="/packages/{row.package}" data-sveltekit-preload-data="off">{row.package}</a>
<span class="text-sm text-gray-500">{row.downloads.toLocaleString()}</span>
</li>
{/each}
</ul>
{:else}
<div class="text-sm text-gray-500">No data yet.</div>
{/if}
</div>
<div class="bg-white p-6 rounded-lg shadow-sm border"> <div class="bg-white p-6 rounded-lg shadow-sm border">
<h3 class="text-lg font-semibold text-gray-900 mb-2">API Access</h3> <h3 class="text-lg font-semibold text-gray-900 mb-2">API Access</h3>

View File

@@ -153,8 +153,9 @@
<div class="min-h-screen bg-gray-50 py-8"> <div class="min-h-screen bg-gray-50 py-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Admin Dashboard</h1> <h1 class="text-3xl font-bold text-gray-900">Not Found</h1>
<p class="mt-2 text-gray-600">Manage data processing and cache operations</p> <p class="mt-2 text-gray-600">The admin dashboard has been removed.</p>
<p class="mt-4"><a href="/" class="text-blue-600 hover:text-blue-800">Return to homepage</a></p>
</div> </div>
{#if error} {#if error}

View File

@@ -0,0 +1,154 @@
import type { RequestHandler } from './$types';
import { dev } from '$app/environment';
import { CacheManager } from '$lib/redis.js';
import { getOverallDownloads, getPythonMajorDownloads, getPythonMinorDownloads, getSystemDownloads, getInstallerDownloads, getVersionDownloads } from '$lib/api.js';
import { ChartJSNodeCanvas } from 'chartjs-node-canvas';
const cache = new CacheManager();
const width = 1200;
const height = 600;
export const GET: RequestHandler = async ({ params, url }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
const type = params.type || 'overall';
const chartType = (url.searchParams.get('chart') || 'line').toLowerCase(); // 'line' | 'bar'
const mirrors = url.searchParams.get('mirrors') || undefined; // for overall
const version = url.searchParams.get('version') || undefined; // for python_* filters
const os = url.searchParams.get('os') || undefined; // for system filter
const format = (url.searchParams.get('format') || '').toLowerCase(); // 'json' to return data only
if (!packageName || packageName === '__all__') {
return new Response('Invalid package', { status: 400 });
}
const cacheKey = `pypistats:chart:${packageName}:${type}:${chartType}:${mirrors || ''}:${version || ''}:${os || ''}`;
const skipCache = dev || url.searchParams.get('nocache') === '1' || url.searchParams.get('cache') === 'false';
if (!skipCache) {
const cached = await cache.get<string>(cacheKey);
if (cached) {
const buffer = Buffer.from(cached, 'base64');
return new Response(buffer, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' } });
}
}
try {
// Fetch data based on type
let rows: Array<{ date: any; category: string; downloads: number | bigint }>;
if (type === 'overall') {
rows = await getOverallDownloads(packageName, mirrors);
} else if (type === 'python_major') {
rows = await getPythonMajorDownloads(packageName, version);
} else if (type === 'python_minor') {
rows = await getPythonMinorDownloads(packageName, version);
} else if (type === 'system') {
rows = await getSystemDownloads(packageName, os);
} else if (type === 'installer') {
rows = await getInstallerDownloads(packageName);
} else if (type === 'version') {
rows = await getVersionDownloads(packageName);
} else {
return new Response('Unknown chart type', { status: 400 });
}
// Group by category -> timeseries
const seriesMap = new Map<string, Array<{ x: string; y: number }>>();
for (const r of rows) {
const key = r.category;
if (!seriesMap.has(key)) seriesMap.set(key, []);
const isoDate = typeof r.date === 'string' ? r.date : new Date(r.date).toISOString().split('T')[0];
seriesMap.get(key)!.push({ x: isoDate, y: Number(r.downloads) });
}
for (const arr of seriesMap.values()) {
arr.sort((a, b) => a.x.localeCompare(b.x));
}
// Build normalized date labels so datasets align with labels
const labels = Array.from(
new Set(
rows.map(r => (typeof r.date === 'string' ? r.date : new Date(r.date).toISOString().split('T')[0]))
)
).sort();
const datasets = Array.from(seriesMap.entries()).map(([label, points], idx) => {
const color = palette[idx % palette.length];
return {
label,
data: labels.map(l => points.find(p => p.x === l)?.y ?? 0),
borderColor: color,
backgroundColor: color + '33',
fill: chartType === 'line'
} as any;
});
// Human-friendly chart title
const typeTitle =
type === 'overall' ? 'Overall downloads (with/without mirrors)'
: type === 'python_major' ? 'Downloads by Python major version'
: type === 'python_minor' ? 'Downloads by Python minor version'
: type === 'system' ? 'Downloads by operating system'
: type === 'installer' ? 'Downloads by installer'
: type === 'version' ? 'Downloads by version'
: `${type} downloads`;
const titleText = `${packageName}${typeTitle}`;
// If JSON/data requested, return data for interactive charts
if (format === 'json' || format === 'data') {
const body = {
package: packageName,
type,
chartType,
title: titleText,
labels,
datasets
};
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' } });
}
const configuration = {
type: chartType as any,
data: { labels, datasets },
options: {
responsive: false,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: titleText }
},
scales: {
x: {
title: { display: true, text: 'Date' },
ticks: { autoSkip: true, maxTicksLimit: 12 }
},
y: {
title: { display: true, text: 'Downloads' },
beginAtZero: true,
ticks: {
callback: (value: any) => {
try { return Number(value).toLocaleString(); } catch { return value; }
}
}
}
}
}
};
const renderer = new ChartJSNodeCanvas({ width, height, backgroundColour: 'white' });
const image = await renderer.renderToBuffer(configuration as any, 'image/png');
// Cache for 1 hour (disabled in dev or when nocache is set)
if (!skipCache) {
await cache.set(cacheKey, image.toString('base64'), 3600);
}
return new Response(image, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' } });
} catch (error) {
console.error('Error rendering chart:', error);
return new Response('Internal server error', { status: 500 });
}
};
const palette = [
'#2563eb', '#16a34a', '#dc2626', '#7c3aed', '#ea580c', '#0891b2', '#ca8a04', '#4b5563'
];

View File

@@ -0,0 +1,60 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/prisma.js';
import { DataProcessor } from '$lib/data-processor.js';
export const GET: RequestHandler = async ({ params }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
if (!packageName || packageName === '__all__') {
return json({ error: 'Invalid package name' }, { status: 400 });
}
try {
const processor = new DataProcessor();
await processor.ensurePackageFreshness(packageName);
const [overallAll, systemAll, pyMajorAll, pyMinorAll] = await Promise.all([
prisma.overallDownloadCount.groupBy({
by: ['category'],
where: { package: packageName },
_sum: { downloads: true }
}),
prisma.systemDownloadCount.groupBy({
by: ['category'],
where: { package: packageName },
_sum: { downloads: true }
}),
prisma.pythonMajorDownloadCount.groupBy({
by: ['category'],
where: { package: packageName },
_sum: { downloads: true }
}),
prisma.pythonMinorDownloadCount.groupBy({
by: ['category'],
where: { package: packageName },
_sum: { downloads: true }
})
]);
const overallTotal = overallAll.reduce((sum, r) => sum + Number(r._sum.downloads || 0), 0);
const systemTotals = Object.fromEntries(systemAll.map(r => [r.category, Number(r._sum.downloads || 0)]));
const pythonMajorTotals = Object.fromEntries(pyMajorAll.map(r => [r.category, Number(r._sum.downloads || 0)]));
const pythonMinorTotals = Object.fromEntries(pyMinorAll.map(r => [r.category, Number(r._sum.downloads || 0)]));
return json({
package: packageName,
type: 'summary',
totals: {
overall: overallTotal,
system: systemTotals,
python_major: pythonMajorTotals,
python_minor: pythonMinorTotals
}
});
} catch (error) {
console.error('Error building package summary:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,89 @@
import {
getRecentDownloads,
getOverallDownloads,
getPythonMajorDownloads,
getPythonMinorDownloads,
getSystemDownloads,
getPackageMetadata,
getInstallerDownloads,
getVersionDownloads
} from '$lib/api.js';
import type { PageServerLoad } from './$types';
// Streaming responses: use native promises in returned object (SvelteKit will stream)
export const load: PageServerLoad = async ({ params }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
if (!packageName || packageName === '__all__') {
return {
packageName,
recentStats: null,
overallStats: [],
pythonMajorStats: [],
pythonMinorStats: [],
systemStats: []
};
}
try {
const recentStatsP = getRecentDownloads(packageName).then((recent) => {
const fm: Record<string, number> = {};
for (const stat of recent) fm[`last_${stat.category}`] = Number(stat.downloads);
return fm;
});
const overallStatsP = getOverallDownloads(packageName);
const pythonMajorStatsP = getPythonMajorDownloads(packageName);
const pythonMinorStatsP = getPythonMinorDownloads(packageName);
const systemStatsP = getSystemDownloads(packageName);
const installerStatsP = getInstallerDownloads(packageName);
const versionStatsP = getVersionDownloads(packageName);
const metaP = getPackageMetadata(packageName);
const summaryTotalsP = Promise.all([
overallStatsP,
pythonMajorStatsP,
pythonMinorStatsP,
systemStatsP,
installerStatsP,
versionStatsP
]).then(([overall, pyMaj, pyMin, system, installer, version]) => {
const sum = <T extends { category: string; downloads: any }>(rows: T[]) => {
const map: Record<string, number> = {};
for (const r of rows) map[r.category] = (map[r.category] || 0) + Number(r.downloads);
return map;
};
return {
overall: sum(overall),
python_major: sum(pyMaj),
python_minor: sum(pyMin),
system: sum(system),
installer: sum(installer),
version: sum(version)
}
});
return {
packageName,
meta: metaP,
recentStats: recentStatsP,
overallStats: overallStatsP,
pythonMajorStats: pythonMajorStatsP,
pythonMinorStats: pythonMinorStatsP,
systemStats: systemStatsP,
summaryTotals: summaryTotalsP
};
} catch (error) {
console.error('Error loading package data:', error);
return {
packageName,
recentStats: null,
overallStats: [],
pythonMajorStats: [],
pythonMinorStats: [],
systemStats: []
};
}
};

View File

@@ -1,155 +1,549 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
const { data } = $props<{ data: PageData }>(); import { onMount, onDestroy } from 'svelte';
const { data }: { data: PageData } = $props();
let overallCanvas: HTMLCanvasElement | null = $state(null);
let pyMajorCanvas: HTMLCanvasElement | null = $state(null);
let pyMinorCanvas: HTMLCanvasElement | null = $state(null);
let systemCanvas: HTMLCanvasElement | null = $state(null);
let charts: any[] = [];
let installerCanvas: HTMLCanvasElement | null = $state(null);
const numberFormatter = new Intl.NumberFormat(undefined);
const compactFormatter = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1
});
function formatNumber(n: number) {
try {
return numberFormatter.format(n);
} catch {
return String(n);
}
}
function formatCompact(n: number) {
try {
return compactFormatter.format(n);
} catch {
return String(n);
}
}
// Streaming handled with {#await} blocks below
// Build combined Python versions rows reactively
type PythonRow = { kind: 'major' | 'minor'; label: string; downloads: number };
function buildPythonVersionRows(
pyMajorTotalsMap: Record<string, number>,
pyMinorTotalsMap: Record<string, number>
): PythonRow[] {
const rows: PythonRow[] = [];
const majorOrder = ['2', '3', 'unknown'];
for (const major of majorOrder) {
const majorDownloads = Number(pyMajorTotalsMap[major] || 0);
if (majorDownloads > 0 || major in pyMajorTotalsMap) {
rows.push({ kind: 'major', label: major, downloads: majorDownloads });
const minors: Array<[string, number]> = Object.entries(pyMinorTotalsMap)
.filter(([k]) => (major === 'unknown' ? k === 'unknown' : k.startsWith(major + '.')))
.map(([k, v]) => [k, Number(v)]);
minors.sort((a, b) => {
const ak =
a[0] === 'unknown' ? Number.POSITIVE_INFINITY : parseFloat(a[0].split('.')[1] || '0');
const bk =
b[0] === 'unknown' ? Number.POSITIVE_INFINITY : parseFloat(b[0].split('.')[1] || '0');
return ak - bk;
});
for (const [minor, dls] of minors)
rows.push({ kind: 'minor', label: minor, downloads: dls });
}
}
return rows;
}
async function loadAndRenderChart(
canvas: HTMLCanvasElement | null,
type: string,
params: Record<string, string> = {}
) {
if (!canvas) return;
const qs = new URLSearchParams({ format: 'json', chart: 'line', ...params });
const resp = await fetch(
`/api/packages/${encodeURIComponent(data.packageName)}/chart/${type}?${qs.toString()}`
);
if (!resp.ok) return;
const payload = await resp.json();
const { default: Chart } = await import('chart.js/auto');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const chart = new Chart(ctx, {
type: (payload.chartType || 'line') as any,
data: { labels: payload.labels, datasets: payload.datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: payload.title },
tooltip: {
callbacks: {
label: (ctx: any) =>
`${ctx.dataset.label}: ${formatNumber(Number(ctx.parsed?.y ?? ctx.raw?.y ?? 0))}`
}
}
},
scales: {
x: {
title: { display: true, text: 'Date' },
ticks: {
autoSkip: true,
maxTicksLimit: 8,
callback: (value: any, index: number, ticks: any[]) => {
const label = (payload.labels?.[index] ?? '').toString();
// Show MM/dd for compactness
const d = new Date(label);
if (!isNaN(d.getTime())) return `${d.getMonth() + 1}/${d.getDate()}`;
return label;
}
}
},
y: {
title: { display: true, text: 'Downloads' },
beginAtZero: true,
ticks: {
callback: (value: any) => formatCompact(Number(value))
}
}
}
}
});
charts.push(chart);
}
onMount(() => {
loadAndRenderChart(overallCanvas, 'overall');
requestAnimationFrame(() => {
loadAndRenderChart(pyMajorCanvas, 'python_major');
loadAndRenderChart(pyMinorCanvas, 'python_minor');
loadAndRenderChart(systemCanvas, 'system');
loadAndRenderChart(installerCanvas, 'installer');
});
});
onDestroy(() => {
for (const c of charts) {
try {
c.destroy();
} catch {}
}
charts = [];
});
</script> </script>
<svelte:head> <svelte:head>
<title>{data.packageName} - PyPI Stats</title> <title>{data.packageName} - PyPI Stats</title>
<meta name="description" content="Download statistics for {data.packageName} package" /> <meta name="description" content="Download statistics for {data.packageName} package" />
</svelte:head> </svelte:head>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">{data.packageName}</h1> <h1 class="mb-2 text-3xl font-bold text-gray-900">{data.packageName}</h1>
<p class="text-gray-600">Download statistics from PyPI</p> <p class="text-gray-600">Download statistics from PyPI</p>
{#if data.meta}
{#await data.meta}
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span
class="inline-flex items-center rounded-full border bg-gray-50 px-2.5 py-1 text-gray-700"
>Loading…</span
>
</div>
{:then meta}
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
{#if meta.version}
<span
class="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-blue-700"
>v{meta.version}</span
>
{/if}
{#if meta.latestReleaseDate}
<span
class="inline-flex items-center rounded-full border border-green-200 bg-green-50 px-2.5 py-1 text-green-700"
>Released {meta.latestReleaseDate}</span
>
{/if}
</div>
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
{#if data.summaryTotals}
{#await data.summaryTotals then totals}
{#if totals?.system}
{#await Promise.resolve(Object.entries(totals.system).sort((a, b) => Number(b[1]) - Number(a[1]))[0]) then topSys}
{#if topSys}
<span
class="inline-flex items-center rounded-full border border-purple-200 bg-purple-50 px-2.5 py-1 text-purple-700"
>Popular system: {topSys[0]}</span
>
{/if}
{/await}
{/if}
{/await}
{/if}
{#if data.summaryTotals}
{#await data.summaryTotals then totals}
{#if totals?.installer}
{#await Promise.resolve(Object.entries(totals.installer).sort((a, b) => Number(b[1]) - Number(a[1]))[0]) then topInst}
{#if topInst}
<span
class="inline-flex items-center rounded-full border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-indigo-700"
>Popular installer: {topInst[0]}</span
>
{/if}
{/await}
{/if}
{/await}
{/if}
{#if data.summaryTotals}
{#await data.summaryTotals then totals}
{#if totals?.version}
{#await Promise.resolve(Object.entries(totals.version).sort((a, b) => Number(b[1]) - Number(a[1]))[0]) then topVer}
{#if topVer}
<span
class="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-amber-700"
>Top version: {topVer[0]}</span
>
{/if}
{/await}
{/if}
{/await}
{/if}
</div>
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
<a
class="inline-flex items-center rounded-full border bg-gray-50 px-2.5 py-1 text-gray-700 hover:bg-gray-100"
href={meta.pypiUrl}
rel="noopener"
target="_blank">View on PyPI</a
>
{#if meta.homePage}
<a
class="inline-flex items-center rounded-full border bg-gray-50 px-2.5 py-1 text-gray-700 hover:bg-gray-100"
href={meta.homePage}
rel="noopener"
target="_blank">Homepage</a
>
{/if}
{#if meta.projectUrls}
{#each Object.entries(meta.projectUrls).filter(([label, url]) => !['homepage'].includes(label.toLowerCase())) as [label, url]}
{#if typeof url === 'string'}
<a
class="inline-flex items-center rounded-full border bg-gray-50 px-2.5 py-1 text-gray-700 hover:bg-gray-100"
href={url}
rel="noopener"
target="_blank">{label}</a
>
{/if}
{/each}
{/if}
</div>
{/await}
{/if}
</div> </div>
<!-- Recent Stats --> <!-- Recent Stats + Consolidated Totals -->
{#if data.recentStats} {#if data.recentStats}
<div class="bg-white rounded-lg shadow-sm border mb-8"> <div class="mb-8 rounded-lg border bg-white shadow-sm">
<div class="px-6 py-4 border-b"> <div class="border-b px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900">Recent Downloads</h2> <h2 class="text-lg font-semibold text-gray-900">Recent Downloads</h2>
</div> </div>
<div class="p-6"> <div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="flex flex-wrap gap-6">
{#each Object.entries(data.recentStats) as [period, count]} <div class="max-w-full min-w-[280px] flex-1 grow">
<div class="text-center"> <h4 class="mb-2 text-sm font-semibold text-gray-700">Recent</h4>
<div class="text-2xl font-bold text-blue-600"> <div class="overflow-x-auto">
{(count as number).toLocaleString()} <table class="min-w-full divide-y divide-gray-200">
</div> <thead>
<div class="text-sm text-gray-500 capitalize"> <tr>
{period.replace('last_', '')} <th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Period</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>Downloads</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#await data.recentStats}
<tr><td class="px-6 py-3 text-sm text-gray-500" colspan="2">Loading…</td></tr>
{:then rs}
{#each [['week', Number((rs as any)?.last_week || 0)], ['month', Number((rs as any)?.last_month || 0)]] as [period, count]}
<tr>
<td class="px-6 py-3 text-sm text-gray-700 capitalize">{period}</td>
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-900"
>{formatNumber(Number(count))}</td
>
</tr>
{/each}
{:catch _}
<tr
><td class="px-6 py-3 text-sm text-red-600" colspan="2">Failed to load</td
></tr
>
{/await}
</tbody>
</table>
</div>
</div>
<div class="max-w-full min-w-[280px] flex-1 grow">
<h4 class="mb-2 text-sm font-semibold text-gray-700">Overall</h4>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Category</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>Downloads</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#await (data as any).summaryTotals}
<tr><td class="px-6 py-3 text-sm text-gray-500" colspan="2">Loading…</td></tr>
{:then totals}
{#each Object.entries(totals?.overall || {}).sort((a, b) => Number(b[1]) - Number(a[1])) as [k, v]}
<tr>
<td class="px-6 py-3 text-sm text-gray-700 capitalize"
>{k.replace(/_/g, ' ')}</td
>
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-900"
>{formatNumber(Number(v))}</td
>
</tr>
{/each}
{:catch _}
<tr
><td class="px-6 py-3 text-sm text-red-600" colspan="2">Failed to load</td
></tr
>
{/await}
</tbody>
</table>
</div>
</div>
<div class="max-w-full min-w-[280px] flex-1 grow">
<h4 class="mb-2 text-sm font-semibold text-gray-700">Systems</h4>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>System</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>Downloads</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#await (data as any).summaryTotals}
<tr><td class="px-6 py-3 text-sm text-gray-500" colspan="2">Loading…</td></tr>
{:then totals}
{#each Object.entries(totals?.system || {}).sort((a, b) => Number(b[1]) - Number(a[1])) as [k, v]}
<tr>
<td class="px-6 py-3 text-sm text-gray-700 capitalize">{k}</td>
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-900"
>{formatNumber(Number(v))}</td
>
</tr>
{/each}
{:catch _}
<tr
><td class="px-6 py-3 text-sm text-red-600" colspan="2">Failed to load</td
></tr
>
{/await}
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-8">
<h3 class="text-md mb-2 font-semibold text-gray-900">Python Versions</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Version</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>Downloads</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#await (data as any).summaryTotals}
<tr><td class="px-6 py-3 text-sm text-gray-500" colspan="2">Loading…</td></tr>
{:then totals}
{#each buildPythonVersionRows(totals?.python_major || {}, totals?.python_minor || {}) as row}
<tr class={row.kind === 'major' ? 'bg-gray-50' : ''}>
<td class="px-6 py-3 text-sm text-gray-700 capitalize">
{#if row.kind === 'major'}
Python {row.label}
{:else}
<span class="inline-block pl-6">{row.label}</span>
{/if}
</td>
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-900"
>{formatNumber(row.downloads)}</td
>
</tr>
{/each}
{:catch _}
<tr><td class="px-6 py-3 text-sm text-red-600" colspan="2">Failed to load</td></tr
>
{/await}
</tbody>
</table>
</div>
</div>
</div>
</div>
{/if}
<!-- Charts -->
{#if data.packageName}
<div class="mb-8 rounded-lg border bg-white shadow-sm">
<div class="border-b px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900">Overall Downloads Over Time</h2>
<p class="text-sm text-gray-500">Includes with and without mirrors</p>
</div>
<div class="p-6">
<div class="w-full overflow-x-auto">
<div class="relative h-96 w-full">
<canvas bind:this={overallCanvas}></canvas>
</div>
</div>
</div>
</div>
{#await data.pythonMajorStats}
<!-- loading placeholder omitted to reduce layout shift -->
{:then majorArr}
{#if majorArr && majorArr.length > 0}
<div class="mb-8 rounded-lg border bg-white shadow-sm">
<div class="border-b px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900">Python Major Versions</h2>
</div>
<div class="p-6">
<div class="w-full overflow-x-auto">
<div class="relative h-96 w-full">
<canvas bind:this={pyMajorCanvas}></canvas>
</div> </div>
</div> </div>
{/each} </div>
</div> </div>
</div> {/if}
</div> {/await}
{/if}
{#await data.pythonMinorStats}
<!-- Overall Stats --> <!-- waiting -->
{#if data.overallStats && data.overallStats.length > 0} {:then minorArr}
<div class="bg-white rounded-lg shadow-sm border mb-8"> {#if minorArr && minorArr.length > 0}
<div class="px-6 py-4 border-b"> <div class="mb-8 rounded-lg border bg-white shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">Overall Downloads</h2> <div class="border-b px-6 py-4">
</div> <h2 class="text-lg font-semibold text-gray-900">Python Minor Versions</h2>
<div class="p-6"> </div>
<div class="overflow-x-auto"> <div class="p-6">
<table class="min-w-full divide-y divide-gray-200"> <div class="w-full overflow-x-auto">
<thead> <div class="relative h-96 w-full">
<tr> <canvas bind:this={pyMinorCanvas}></canvas>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> </div>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th> </div>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th> </div>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.overallStats.slice(0, 10) as stat}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.date}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{stat.category}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.downloads.toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
</div> </div>
</div> {/if}
</div> {/await}
{/if}
{#await data.systemStats}
<!-- Python Version Stats --> <!-- waiting -->
{#if data.pythonMajorStats && data.pythonMajorStats.length > 0} {:then sysArr}
<div class="bg-white rounded-lg shadow-sm border mb-8"> {#if sysArr && sysArr.length > 0}
<div class="px-6 py-4 border-b"> <div class="mb-8 rounded-lg border bg-white shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">Python Major Version Downloads</h2> <div class="border-b px-6 py-4">
</div> <h2 class="text-lg font-semibold text-gray-900">System Downloads</h2>
<div class="p-6"> </div>
<div class="overflow-x-auto"> <div class="p-6">
<table class="min-w-full divide-y divide-gray-200"> <div class="w-full overflow-x-auto">
<thead> <div class="relative h-96 w-full">
<tr> <canvas bind:this={systemCanvas}></canvas>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> </div>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th> </div>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th> </div>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.pythonMajorStats.slice(0, 10) as stat}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.date}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{stat.category}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.downloads.toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
</div> </div>
</div> {/if}
</div> {/await}
{/if}
{#await (data as any).summaryTotals}
<!-- System Stats --> <!-- waiting -->
{#if data.systemStats && data.systemStats.length > 0} {:then totals}
<div class="bg-white rounded-lg shadow-sm border mb-8"> {#if totals && totals.installer}
<div class="px-6 py-4 border-b"> <div class="mb-8 rounded-lg border bg-white shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">System Downloads</h2> <div class="border-b px-6 py-4">
</div> <h2 class="text-lg font-semibold text-gray-900">Installer Breakdown</h2>
<div class="p-6"> </div>
<div class="overflow-x-auto"> <div class="p-6">
<table class="min-w-full divide-y divide-gray-200"> <div class="relative h-96 w-full">
<thead> <canvas bind:this={installerCanvas}></canvas>
<tr> </div>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> </div>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">System</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.systemStats.slice(0, 10) as stat}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.date}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{stat.category}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.downloads.toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
</div> </div>
</div> {/if}
</div> {/await}
{/if} {/if}
<!-- API Links --> <!-- API Links -->
<div class="bg-blue-50 rounded-lg p-6"> <div class="rounded-lg bg-blue-50 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">API Access</h3> <h3 class="mb-4 text-lg font-semibold text-gray-900">API Access</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
<div> <div>
<strong>Recent downloads:</strong> <strong>Recent downloads:</strong>
<a href="/api/packages/{data.packageName}/recent" class="text-blue-600 hover:text-blue-800 ml-2">JSON</a> <a
href="/api/packages/{data.packageName}/recent"
class="ml-2 text-blue-600 hover:text-blue-800">JSON</a
>
</div> </div>
<div> <div>
<strong>Overall downloads:</strong> <strong>Overall downloads:</strong>
<a href="/api/packages/{data.packageName}/overall" class="text-blue-600 hover:text-blue-800 ml-2">JSON</a> <a
href="/api/packages/{data.packageName}/overall"
class="ml-2 text-blue-600 hover:text-blue-800">JSON</a
>
</div> </div>
<div> <div>
<strong>Python major versions:</strong> <strong>Python major versions:</strong>
<a href="/api/packages/{data.packageName}/python_major" class="text-blue-600 hover:text-blue-800 ml-2">JSON</a> <a
href="/api/packages/{data.packageName}/python_major"
class="ml-2 text-blue-600 hover:text-blue-800">JSON</a
>
</div> </div>
<div> <div>
<strong>System downloads:</strong> <strong>System downloads:</strong>
<a href="/api/packages/{data.packageName}/system" class="text-blue-600 hover:text-blue-800 ml-2">JSON</a> <a
href="/api/packages/{data.packageName}/system"
class="ml-2 text-blue-600 hover:text-blue-800">JSON</a
>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,59 +0,0 @@
import {
getRecentDownloads,
getOverallDownloads,
getPythonMajorDownloads,
getPythonMinorDownloads,
getSystemDownloads
} from '$lib/api.js';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
if (!packageName || packageName === '__all__') {
return {
packageName,
recentStats: null,
overallStats: [],
pythonMajorStats: [],
pythonMinorStats: [],
systemStats: []
};
}
try {
// Fetch all statistics in parallel
const [recentStats, overallStats, pythonMajorStats, pythonMinorStats, systemStats] = await Promise.all([
getRecentDownloads(packageName),
getOverallDownloads(packageName),
getPythonMajorDownloads(packageName),
getPythonMinorDownloads(packageName),
getSystemDownloads(packageName)
]);
// Process recent stats into the expected format
const recentStatsFormatted: Record<string, number> = {};
for (const stat of recentStats) {
recentStatsFormatted[`last_${stat.category}`] = Number(stat.downloads);
}
return {
packageName,
recentStats: recentStatsFormatted,
overallStats,
pythonMajorStats,
pythonMinorStats,
systemStats
};
} catch (error) {
console.error('Error loading package data:', error);
return {
packageName,
recentStats: null,
overallStats: [],
pythonMajorStats: [],
pythonMinorStats: [],
systemStats: []
};
}
};

View File

@@ -1,7 +1,6 @@
import { searchPackages } from '$lib/api.js'; import { searchPackages } from '$lib/api.js';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => { export const load = async ({ url }) => {
const searchTerm = url.searchParams.get('q'); const searchTerm = url.searchParams.get('q');
if (!searchTerm) { if (!searchTerm) {
@@ -25,30 +24,5 @@ export const load: PageLoad = async ({ url }) => {
}; };
} }
}; };
export const actions = {
default: async ({ request }) => {
const formData = await request.formData();
const searchTerm = formData.get('q');
if (!searchTerm) {
return {
packages: [],
searchTerm: null
};
}
try {
const packages = await searchPackages(searchTerm.toString());
return {
packages,
searchTerm
};
} catch (error) {
console.error('Error searching packages:', error);
return {
packages: [],
searchTerm
};
}
}
};

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import type { PageData } from './$types'; import type { PageData } from './$types';
const { data } = $props<{ data: PageData }>(); const { data } = $props<{ data: PageData }>();
let searchTerm = $state(''); let searchTerm = $state(data.searchTerm ?? '');
</script> </script>
<svelte:head> <svelte:head>
@@ -14,7 +13,7 @@
<h1 class="text-3xl font-bold text-gray-900 mb-8">Search Packages</h1> <h1 class="text-3xl font-bold text-gray-900 mb-8">Search Packages</h1>
<!-- Search Form --> <!-- Search Form -->
<form method="GET" action="/search" use:enhance class="mb-8"> <form method="GET" action="/search" class="mb-8">
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
type="text" type="text"
@@ -43,7 +42,7 @@
<div class="divide-y divide-gray-200"> <div class="divide-y divide-gray-200">
{#each data.packages as pkg} {#each data.packages as pkg}
<div class="px-6 py-4 hover:bg-gray-50"> <div class="px-6 py-4 hover:bg-gray-50">
<a href="/packages/{pkg}" class="block"> <a href="/packages/{pkg}" class="block" data-sveltekit-preload-data="off">
<div class="text-lg font-medium text-blue-600 hover:text-blue-800"> <div class="text-lg font-medium text-blue-600 hover:text-blue-800">
{pkg} {pkg}
</div> </div>