diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..b7e1638 --- /dev/null +++ b/.env.dev @@ -0,0 +1,6 @@ +APP_URL=http://localhost:3000 +AUTH_TOKEN=1234567890 +STORAGE_TYPE=turso +TURSO_DB_URL=:memory: +TURSO_AUTH_TOKEN= +ENABLE_LOGS=false \ No newline at end of file diff --git a/.env.dist b/.env.dist index 9daa381..b7e1638 100644 --- a/.env.dist +++ b/.env.dist @@ -1,3 +1,6 @@ -APP_URL= -AUTH_TOKEN= +APP_URL=http://localhost:3000 +AUTH_TOKEN=1234567890 +STORAGE_TYPE=turso +TURSO_DB_URL=:memory: +TURSO_AUTH_TOKEN= ENABLE_LOGS=false \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..bc347e7 --- /dev/null +++ b/.env.test @@ -0,0 +1,6 @@ +APP_URL=http://localhost:3000 +AUTH_TOKEN=test_token +STORAGE_TYPE=turso +TURSO_DB_URL=:memory: +TURSO_DB_AUTH_TOKEN=test_token +ENABLE_LOGS=false \ No newline at end of file diff --git a/.github/DEPLOYMENT_SETUP.md b/.github/DEPLOYMENT_SETUP.md new file mode 100644 index 0000000..94d7f62 --- /dev/null +++ b/.github/DEPLOYMENT_SETUP.md @@ -0,0 +1,261 @@ +# ๐Ÿš€ Deployment Setup Guide + +This guide explains how to set up GitHub workflows for automated CI/CD with Deno Deploy. + +## ๐Ÿ“‹ Prerequisites + +1. **GitHub Repository** with admin access +2. **Deno Deploy Account** ([signup](https://deno.com/deploy)) +3. **Turso Account** for production database ([signup](https://turso.tech)) + +## ๐Ÿ” Required Secrets + +Configure these secrets in your GitHub repository settings (`Settings > Secrets and variables > Actions`): + +### **Repository Secrets** + +```bash +# Deno Deploy Integration +DENO_DEPLOY_TOKEN=ddp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Production Environment +PRODUCTION_AUTH_TOKEN=your-secure-production-auth-token-here +PRODUCTION_TURSO_DB_URL=libsql://your-database-url.turso.io +PRODUCTION_TURSO_DB_AUTH_TOKEN=your-turso-auth-token-here +PRODUCTION_TEST_TOKEN=token-for-post-deployment-testing + +# Staging Environment +STAGING_AUTH_TOKEN=your-staging-auth-token-here +STAGING_TURSO_DB_URL=libsql://your-staging-database-url.turso.io +STAGING_TURSO_DB_AUTH_TOKEN=your-staging-turso-auth-token-here + +# Preview Environment +PREVIEW_AUTH_TOKEN=preview-token-12345 +``` + +### **Repository Variables** + +Configure these variables in `Settings > Secrets and variables > Actions > Variables`: + +```bash +# Production Configuration +PRODUCTION_STORAGE_TYPE=TURSO +PRODUCTION_ENABLE_LOGS=true +PRODUCTION_ENABLE_AUTH=true + +# Staging Configuration +STAGING_STORAGE_TYPE=KV +STAGING_ENABLE_LOGS=true +STAGING_ENABLE_AUTH=true +``` + +## ๐Ÿ—๏ธ Deno Deploy Setup + +### 1. Create Deno Deploy Projects + +Create these projects in your [Deno Deploy dashboard](https://dash.deno.com): + +- `done-production` - Production environment +- `done-staging` - Staging environment +- `done-preview-pr-{number}` - Created automatically for PR previews + +### 2. Get Deno Deploy Token + +1. Go to [Deno Deploy Settings](https://dash.deno.com/account/settings) +2. Create a new **Access Token** +3. Copy the token and add it as `DENO_DEPLOY_TOKEN` secret in GitHub + +### 3. Configure Project Settings + +For each project, configure: + +**Production Project (`done-production`):** +- **Custom Domain**: `done.yourdomain.com` (optional) +- **Environment Variables**: Set via GitHub workflow (automatic) + +**Staging Project (`done-staging`):** +- **Custom Domain**: `done-staging.yourdomain.com` (optional) +- **Environment Variables**: Set via GitHub workflow (automatic) + +## ๐Ÿ—„๏ธ Database Setup + +### Turso Database Configuration + +1. **Create Turso Databases:** + ```bash + # Production database + turso db create done-production + + # Staging database + turso db create done-staging + ``` + +2. **Get Connection Details:** + ```bash + # Get database URLs + turso db show done-production + turso db show done-staging + + # Create auth tokens + turso db tokens create done-production + turso db tokens create done-staging + ``` + +3. **Run Migrations:** + ```bash + # Production + turso db shell done-production < migrations/000_create_migrations_table.sql + turso db shell done-production < migrations/001_create_messages_table.sql + turso db shell done-production < migrations/002_create_logs_table.sql + + # Staging + turso db shell done-staging < migrations/000_create_migrations_table.sql + turso db shell done-staging < migrations/001_create_messages_table.sql + turso db shell done-staging < migrations/002_create_logs_table.sql + ``` + +## โš™๏ธ GitHub Repository Settings + +### Branch Protection Rules + +Set up branch protection for `main` branch (`Settings > Branches`): + +```yaml +Protection Rules for 'main': +โœ… Require a pull request before merging + โœ… Require approvals: 1 + โœ… Dismiss stale PR approvals when new commits are pushed + โœ… Require review from code owners + +โœ… Require status checks to pass before merging + โœ… Require branches to be up to date before merging + Required Status Checks: + - Test & Lint + - Security Scan + - API Validation + +โœ… Require conversation resolution before merging +โœ… Include administrators (recommended) +``` + +### Environment Protection Rules + +Configure environment protection (`Settings > Environments`): + +**Production Environment:** +- โœ… Required reviewers: [Your GitHub username] +- โœ… Wait timer: 5 minutes +- โœ… Deployment branches: `main` only + +**Staging Environment:** +- โœ… Deployment branches: All branches + +## ๐Ÿ”„ Workflow Overview + +### **CI Workflow** (`ci.yml`) +**Triggers:** PRs to main, pushes to main +**Steps:** +1. Format & lint checks +2. Type checking +3. Run tests (KV + Turso) +4. Security scanning +5. API validation +6. PR status comments + +### **Deployment Workflow** (`deploy.yml`) +**Triggers:** CI success, manual dispatch +**Steps:** +1. Run comprehensive tests +2. Deploy to staging/production +3. Health checks +4. Smoke tests (production) +5. Rollback on failure + +### **Preview Workflow** (`preview.yml`) +**Triggers:** PR opened/updated +**Steps:** +1. Deploy PR to preview environment +2. Health check +3. Comment PR with preview URL +4. Cleanup on PR close + +## ๐Ÿงช Testing the Setup + +### 1. Test CI Pipeline +Create a test PR: +```bash +git checkout -b test/ci-setup +echo "# Test CI" >> TEST.md +git add TEST.md +git commit -m "test: verify CI pipeline" +git push origin test/ci-setup +``` + +### 2. Test Deployment +Merge to main or trigger manual deployment: +```bash +# Via GitHub UI: Actions > Deploy to Deno Deploy > Run workflow +``` + +### 3. Verify Deployments +Check your deployed applications: +- **Production**: `https://done-production.deno.dev/v1/system/ping` +- **Staging**: `https://done-staging.deno.dev/v1/system/ping` + +## ๐Ÿšจ Troubleshooting + +### Common Issues + +**โŒ "DENO_DEPLOY_TOKEN not found"** +- Verify token is set in repository secrets +- Ensure token has correct permissions + +**โŒ "Database connection failed"** +- Check Turso URL and auth token +- Verify database exists and migrations ran + +**โŒ "Tests failing in CI"** +- Run tests locally: `deno task test` +- Check environment variables +- Verify all dependencies are available + +**โŒ "Deployment health check failed"** +- Check Deno Deploy logs +- Verify environment variables are set +- Test endpoints manually + +### Getting Help + +1. **Check workflow logs** in GitHub Actions tab +2. **Review Deno Deploy logs** in dashboard +3. **Test locally** with same environment variables +4. **Check documentation** for latest updates + +## ๐Ÿ”ง Maintenance + +### Regular Tasks + +1. **Monitor deployments** via Deno Deploy dashboard +2. **Review dependency updates** from Dependabot +3. **Rotate secrets** every 90 days +4. **Update environment variables** as needed +5. **Review and update workflows** quarterly + +### Security Best Practices + +- โœ… Use environment-specific tokens +- โœ… Rotate secrets regularly +- โœ… Limit token permissions +- โœ… Review access logs +- โœ… Monitor for unauthorized deployments + +--- + +## ๐Ÿ“š Additional Resources + +- [Deno Deploy Documentation](https://deno.com/deploy/docs) +- [GitHub Actions Documentation](https://docs.github.com/en/actions) +- [Turso Documentation](https://docs.turso.tech) +- [Repository Settings Guide](https://docs.github.com/en/repositories) + +โœ… **Your CI/CD pipeline is now ready for production!** ๐ŸŽ‰ \ No newline at end of file diff --git a/.github/WORKFLOW_BADGES.md b/.github/WORKFLOW_BADGES.md new file mode 100644 index 0000000..08c0d59 --- /dev/null +++ b/.github/WORKFLOW_BADGES.md @@ -0,0 +1,62 @@ +# ๐Ÿ“Š Workflow Status Badges + +Add these badges to your README.md to show the status of your workflows: + +## Copy-Paste Ready Badges + +```markdown +[![CI](https://github.com/dnl-fm/done/actions/workflows/ci.yml/badge.svg)](https://github.com/dnl-fm/done/actions/workflows/ci.yml) +[![Deploy](https://github.com/dnl-fm/done/actions/workflows/deploy.yml/badge.svg)](https://github.com/dnl-fm/done/actions/workflows/deploy.yml) +[![Code Quality](https://github.com/dnl-fm/done/actions/workflows/code-quality.yml/badge.svg)](https://github.com/dnl-fm/done/actions/workflows/code-quality.yml) +``` + +## Individual Badges + +### CI Pipeline +```markdown +[![CI](https://github.com/dnl-fm/done/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/dnl-fm/done/actions/workflows/ci.yml) +``` + +### Deployment Status +```markdown +[![Deploy](https://github.com/dnl-fm/done/actions/workflows/deploy.yml/badge.svg?branch=main)](https://github.com/dnl-fm/done/actions/workflows/deploy.yml) +``` + +### Code Quality +```markdown +[![Code Quality](https://github.com/dnl-fm/done/actions/workflows/code-quality.yml/badge.svg)](https://github.com/dnl-fm/done/actions/workflows/code-quality.yml) +``` + +### Preview Deployments +```markdown +[![Preview](https://github.com/dnl-fm/done/actions/workflows/preview.yml/badge.svg)](https://github.com/dnl-fm/done/actions/workflows/preview.yml) +``` + +## Custom Status Section + +```markdown +## ๐Ÿš€ Project Status + +| Service | Status | Environment | URL | +|---------|--------|-------------|-----| +| Production | [![Deploy](https://github.com/dnl-fm/done/actions/workflows/deploy.yml/badge.svg)](https://github.com/dnl-fm/done/actions/workflows/deploy.yml) | Production | [done.deno.dev](https://done.deno.dev) | +| Staging | [![Deploy](https://github.com/dnl-fm/done/actions/workflows/deploy.yml/badge.svg)](https://github.com/dnl-fm/done/actions/workflows/deploy.yml) | Staging | [done-staging.deno.dev](https://done-staging.deno.dev) | +| Tests | [![CI](https://github.com/dnl-fm/done/actions/workflows/ci.yml/badge.svg)](https://github.com/dnl-fm/done/actions/workflows/ci.yml) | - | - | +| Code Quality | [![Code Quality](https://github.com/dnl-fm/done/actions/workflows/code-quality.yml/badge.svg)](https://github.com/dnl-fm/done/actions/workflows/code-quality.yml) | - | - | +``` + +## Recommendation + +Add this section to the top of your README.md after the title: + +```markdown +# Done - Webhook Queue Service + +[![CI](https://github.com/dnl-fm/done/actions/workflows/ci.yml/badge.svg)](https://github.com/dnl-fm/done/actions/workflows/ci.yml) +[![Deploy](https://github.com/dnl-fm/done/actions/workflows/deploy.yml/badge.svg)](https://github.com/dnl-fm/done/actions/workflows/deploy.yml) +[![Code Quality](https://github.com/dnl-fm/done/actions/workflows/code-quality.yml/badge.svg)](https://github.com/dnl-fm/done/actions/workflows/code-quality.yml) + +> A reliable webhook delivery service with dual storage support (Deno KV + Turso) +``` + +**Note:** Replace `dnl-fm/done` with your actual GitHub repository path if different. \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..91209e9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: + # Enable version updates for Deno dependencies + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + reviewers: + - "dnl-fm" # Replace with your GitHub username + assignees: + - "dnl-fm" # Replace with your GitHub username + commit-message: + prefix: "chore" + include: "scope" + labels: + - "dependencies" + - "github-actions" + open-pull-requests-limit: 5 + + # Monitor workflow changes + - package-ecosystem: "gitsubmodule" + directory: "/" + schedule: + interval: "weekly" + reviewers: + - "dnl-fm" + labels: + - "dependencies" + - "submodule" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..966d079 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,175 @@ +name: Continuous Integration + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +jobs: + test: + name: Test & Lint + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: write # For PR comments + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.3.3 + + - name: Verify Deno installation + run: deno --version + + - name: Cache Deno dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cache/deno + ~/.deno + key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }} + restore-keys: | + ${{ runner.os }}-deno- + + - name: Check formatting + run: deno fmt --check + + - name: Run linter + run: deno lint + + - name: Type check + run: deno check src/main.ts + + - name: Run tests + run: deno task test + env: + # Enable logs for testing + ENABLE_LOGS: true + # Use KV storage for CI to avoid libsql native binary issues + STORAGE_TYPE: KV + + - name: Generate test coverage (if available) + run: | + if deno task coverage 2>/dev/null; then + echo "Coverage report generated" + else + echo "No coverage task defined, skipping" + fi + continue-on-error: true + + security: + name: Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.3.3 + + - name: Security audit + run: | + # Check for common security issues + echo "Checking for hardcoded secrets..." + if grep -r -E "(password|secret|api_?key|auth_?token|private_?key)\s*[=:]\s*['\"][^'\"]{8,}" src/ --include="*.ts" --exclude-dir=node_modules | grep -v "placeholder\|example\|test\|TODO\|foreign_keys\|PRAGMA"; then + echo "โŒ Potential hardcoded secrets found" + exit 1 + else + echo "โœ… No hardcoded secrets detected" + fi + + - name: Dependency vulnerability check + run: | + echo "Checking dependencies for known vulnerabilities..." + # This will be enhanced when Deno gets better tooling for this + deno info src/main.ts > /dev/null + echo "โœ… Dependencies check completed" + + validate-api: + name: API Validation + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.3.3 + + - name: Start application + run: | + deno task start & + APP_PID=$! + echo "APP_PID=$APP_PID" >> $GITHUB_ENV + + # Wait for app to start + sleep 5 + + # Basic health check + if curl -f http://localhost:3001/v1/system/ping; then + echo "โœ… Application started successfully" + else + echo "โŒ Application failed to start" + kill $APP_PID 2>/dev/null || true + exit 1 + fi + + kill $APP_PID 2>/dev/null || true + timeout-minutes: 2 + env: + # Use in-memory database for API validation + TURSO_DB_URL: ":memory:" + STORAGE_TYPE: KV + ENABLE_LOGS: true + + notify: + name: Notify Status + runs-on: ubuntu-latest + needs: [test, security, validate-api] + if: always() + + permissions: + contents: read + pull-requests: write # For PR comments + issues: write # For issue comments + + steps: + - name: Check overall status + run: | + if [[ "${{ needs.test.result }}" == "success" && "${{ needs.security.result }}" == "success" && "${{ needs.validate-api.result }}" == "success" ]]; then + echo "โœ… All checks passed! Ready for deployment." + echo "STATUS=success" >> $GITHUB_ENV + else + echo "โŒ Some checks failed. Please review before merging." + echo "STATUS=failure" >> $GITHUB_ENV + exit 1 + fi + + - name: Comment PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v6 + with: + script: | + const status = process.env.STATUS; + const message = status === 'success' + ? 'โœ… All CI checks passed! This PR is ready for review and merge.' + : 'โŒ Some CI checks failed. Please fix the issues before merging.'; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## CI Status Report\n\n${message}\n\n**Test Results:**\n- Tests: ${{ needs.test.result }}\n- Security: ${{ needs.security.result }}\n- API Validation: ${{ needs.validate-api.result }}` + }); \ No newline at end of file diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..7f75dcb --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,191 @@ +name: Code Quality + +on: + push: + branches: [ main, 'feature/**' ] + pull_request: + branches: [ main ] + +jobs: + quality: + name: Code Quality Checks + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.3.3 + + - name: Check code formatting + run: | + echo "๐ŸŽจ Checking code formatting..." + if ! deno fmt --check; then + echo "โŒ Code formatting issues found. Run 'deno fmt' to fix." + exit 1 + fi + echo "โœ… Code formatting is correct" + + - name: Run linter + run: | + echo "๐Ÿ” Running linter..." + if ! deno lint; then + echo "โŒ Linting issues found. Please fix the issues above." + exit 1 + fi + echo "โœ… No linting issues found" + + - name: Type checking + run: | + echo "๐Ÿ”ฌ Running type checks..." + if ! deno check src/main.ts; then + echo "โŒ TypeScript type checking failed." + exit 1 + fi + echo "โœ… Type checking passed" + + - name: Check for TODO/FIXME comments + run: | + echo "๐Ÿ“ Checking for TODO/FIXME comments..." + todos=$(grep -r "TODO\|FIXME\|XXX\|HACK" src/ tests/ --include="*.ts" || true) + if [ ! -z "$todos" ]; then + echo "โš ๏ธ Found TODO/FIXME comments:" + echo "$todos" + echo "" + echo "Consider addressing these before merging to main." + else + echo "โœ… No TODO/FIXME comments found" + fi + + - name: Check import organization + run: | + echo "๐Ÿ“ฆ Checking import organization..." + # Check for relative imports that could be absolute + relative_imports=$(grep -r "from '\.\./\.\." src/ --include="*.ts" || true) + if [ ! -z "$relative_imports" ]; then + echo "โš ๏ธ Found deeply nested relative imports:" + echo "$relative_imports" + echo "Consider using absolute imports for better maintainability." + fi + + - name: Dependency analysis + run: | + echo "๐Ÿ”— Analyzing dependencies..." + deno info src/main.ts --json > deps.json + + # Count total dependencies + deps_count=$(cat deps.json | grep -o '"specifier"' | wc -l) + echo "๐Ÿ“Š Total dependencies: $deps_count" + + # Check for unstable APIs + unstable_apis=$(grep -r "Deno\..*" src/ --include="*.ts" | grep -v "Deno.env\|Deno.serve\|Deno.openKv\|Deno.cron" || true) + if [ ! -z "$unstable_apis" ]; then + echo "โš ๏ธ Found potentially unstable Deno APIs:" + echo "$unstable_apis" + fi + + rm -f deps.json + + - name: Code complexity check + run: | + echo "๐Ÿงฎ Checking code complexity..." + # Simple complexity check - count deeply nested functions + complex_files=$(find src/ -name "*.ts" -exec grep -l "function.*{.*function.*{.*function.*{" {} \; || true) + if [ ! -z "$complex_files" ]; then + echo "โš ๏ธ Found potentially complex files with deep nesting:" + echo "$complex_files" + echo "Consider refactoring for better maintainability." + else + echo "โœ… No overly complex files detected" + fi + + - name: Security scan + run: | + echo "๐Ÿ”’ Running basic security scan..." + + # Check for potential security issues + security_issues="" + + # Check for eval usage + eval_usage=$(grep -r "eval(" src/ --include="*.ts" || true) + if [ ! -z "$eval_usage" ]; then + security_issues="$security_issues\nโŒ Found eval() usage (potential security risk)" + fi + + # Check for innerHTML usage + innerHTML_usage=$(grep -r "innerHTML" src/ --include="*.ts" || true) + if [ ! -z "$innerHTML_usage" ]; then + security_issues="$security_issues\nโš ๏ธ Found innerHTML usage (potential XSS risk)" + fi + + # Check for console.log in production code + console_logs=$(grep -r "console\.log\|console\.error\|console\.warn" src/ --include="*.ts" --exclude="**/utils/logger.ts" || true) + if [ ! -z "$console_logs" ]; then + security_issues="$security_issues\nโš ๏ธ Found console statements in source code" + fi + + if [ ! -z "$security_issues" ]; then + echo "Security scan results:" + echo -e "$security_issues" + else + echo "โœ… Basic security scan passed" + fi + + documentation: + name: Documentation Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check README exists and is updated + run: | + if [ ! -f README.md ]; then + echo "โŒ README.md not found" + exit 1 + fi + + # Check if README was updated recently (within last 30 commits) + readme_updated=$(git log --oneline -30 --name-only | grep README.md || true) + if [ -z "$readme_updated" ]; then + echo "โš ๏ธ README.md hasn't been updated in the last 30 commits" + echo "Consider updating documentation when adding new features." + else + echo "โœ… README.md is being maintained" + fi + + - name: Check API documentation + run: | + echo "๐Ÿ“š Checking API documentation..." + + # Check if Bruno collection exists and is maintained + if [ -d "docs/bruno-collection" ]; then + echo "โœ… Bruno API collection found" + + # Count API endpoints + endpoint_count=$(find docs/bruno-collection -name "*.bru" | wc -l) + echo "๐Ÿ“Š API endpoints documented: $endpoint_count" + else + echo "โš ๏ธ API documentation not found" + fi + + - name: Check code comments + run: | + echo "๐Ÿ’ฌ Analyzing code comments..." + + # Count files with and without JSDoc comments + ts_files=$(find src/ -name "*.ts" | wc -l) + files_with_jsdoc=$(grep -l "/\*\*" src/**/*.ts | wc -l || echo "0") + + echo "๐Ÿ“Š TypeScript files: $ts_files" + echo "๐Ÿ“Š Files with JSDoc: $files_with_jsdoc" + + if [ "$files_with_jsdoc" -lt "$((ts_files / 2))" ]; then + echo "โš ๏ธ Consider adding more JSDoc comments for better documentation" + else + echo "โœ… Good documentation coverage" + fi \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..37d8cdc --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,212 @@ +name: Deploy to Deno Deploy + +on: + push: + branches: [ main ] + workflow_run: + workflows: ["Continuous Integration"] + types: [completed] + branches: [ main ] + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy to' + required: true + default: 'staging' + type: choice + options: + - staging + - production + +jobs: + deploy-staging: + name: Deploy to Staging + runs-on: ubuntu-latest + if: | + (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'staging') || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.ref != 'refs/heads/main') || + (github.event_name == 'push' && github.ref != 'refs/heads/main') + + permissions: + contents: read + id-token: write # Required for OIDC token + + environment: + name: staging + url: https://done-light.deno.dev + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.3.3 + + - name: Run final tests before deployment + run: deno task test + env: + ENABLE_LOGS: true + STORAGE_TYPE: KV + + - name: Deploy to Deno Deploy (Staging) + uses: denoland/deployctl@v1 + with: + project: "done-light" # Deno Deploy project name + entrypoint: "src/main.ts" + root: "." + include: "deno.json" + exclude: | + tests/ + docs/ + .github/ + README.md + .gitignore + env: + DENO_DEPLOY_TOKEN: ${{ secrets.DENO_DEPLOY_TOKEN }} + # Staging environment variables + AUTH_TOKEN: ${{ secrets.STAGING_AUTH_TOKEN }} + TURSO_DB_URL: ${{ secrets.STAGING_TURSO_DB_URL }} + TURSO_DB_AUTH_TOKEN: ${{ secrets.STAGING_TURSO_DB_AUTH_TOKEN }} + STORAGE_TYPE: ${{ vars.STAGING_STORAGE_TYPE || 'KV' }} + ENABLE_LOGS: ${{ vars.STAGING_ENABLE_LOGS || 'true' }} + ENABLE_AUTH: ${{ vars.STAGING_ENABLE_AUTH || 'true' }} + + - name: Staging deployment health check + run: | + echo "Waiting for deployment to be ready..." + sleep 30 + + # Health check with retry + for i in {1..5}; do + if curl -f "https://done-light.deno.dev/v1/system/ping"; then + echo "โœ… Staging deployment is healthy" + break + else + echo "โณ Attempt $i failed, retrying in 10s..." + sleep 10 + fi + + if [ $i -eq 5 ]; then + echo "โŒ Staging deployment health check failed" + exit 1 + fi + done + + deploy-production: + name: Deploy to Production + runs-on: ubuntu-latest + if: | + (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'production') || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.ref == 'refs/heads/main') || + (github.event_name == 'push' && github.ref == 'refs/heads/main') + + permissions: + contents: read + id-token: write # Required for OIDC token + + environment: + name: production + url: https://done-light.deno.dev + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.3.3 + + - name: Run comprehensive tests before production deployment + run: deno task test + env: + ENABLE_LOGS: true + # Use KV storage for pre-deployment tests to avoid libsql issues + STORAGE_TYPE: KV + + - name: Deploy to Deno Deploy (Production) + uses: denoland/deployctl@v1 + with: + project: "done-light" # Deno Deploy project name + entrypoint: "src/main.ts" + root: "." + include: "deno.json" + exclude: | + tests/ + docs/ + .github/ + README.md + .gitignore + bruno-collection/ + env: + DENO_DEPLOY_TOKEN: ${{ secrets.DENO_DEPLOY_TOKEN }} + # Production environment variables + AUTH_TOKEN: ${{ secrets.PRODUCTION_AUTH_TOKEN }} + TURSO_DB_URL: ${{ secrets.PRODUCTION_TURSO_DB_URL }} + TURSO_DB_AUTH_TOKEN: ${{ secrets.PRODUCTION_TURSO_DB_AUTH_TOKEN }} + STORAGE_TYPE: ${{ vars.PRODUCTION_STORAGE_TYPE || 'TURSO' }} + ENABLE_LOGS: ${{ vars.PRODUCTION_ENABLE_LOGS || 'true' }} + ENABLE_AUTH: ${{ vars.PRODUCTION_ENABLE_AUTH || 'true' }} + + - name: Production deployment health check + run: | + echo "Waiting for production deployment to be ready..." + sleep 45 + + # Health check with retry + for i in {1..10}; do + if curl -f "https://done-light.deno.dev/v1/system/ping"; then + echo "โœ… Production deployment is healthy" + break + else + echo "โณ Attempt $i failed, retrying in 15s..." + sleep 15 + fi + + if [ $i -eq 10 ]; then + echo "โŒ Production deployment health check failed" + exit 1 + fi + done + + - name: Run post-deployment smoke tests + run: | + echo "Running post-deployment smoke tests..." + + # Test system endpoints + curl -f "https://done-light.deno.dev/v1/system/ping" || exit 1 + + # Test with auth (using a test token if available) + if [ ! -z "${{ secrets.PRODUCTION_TEST_TOKEN }}" ]; then + curl -f -H "Authorization: Bearer ${{ secrets.PRODUCTION_TEST_TOKEN }}" \ + "https://done-light.deno.dev/v1/system/health" || exit 1 + fi + + echo "โœ… Smoke tests passed" + + - name: Notify deployment success + run: | + echo "๐Ÿš€ Production deployment successful!" + echo "๐Ÿ“Š Application metrics will be available at the monitoring dashboard" + echo "๐Ÿ”— API Documentation: https://done-light.deno.dev/v1/system/ping" + + rollback: + name: Emergency Rollback + runs-on: ubuntu-latest + if: failure() && (github.ref == 'refs/heads/main') + needs: [deploy-production] + + environment: + name: production + + steps: + - name: Trigger rollback procedure + run: | + echo "๐Ÿšจ EMERGENCY: Production deployment failed!" + echo "Manual intervention required for rollback." + echo "Contact the on-call engineer immediately." + # In a real scenario, you might trigger automatic rollback here + # or send alerts to monitoring systems + exit 1 \ No newline at end of file diff --git a/.migrations/001_create_messages_table.sql b/.migrations/001_create_messages_table.sql new file mode 100644 index 0000000..0c72c74 --- /dev/null +++ b/.migrations/001_create_messages_table.sql @@ -0,0 +1,24 @@ +-- Create migrations table if it doesn't exist +CREATE TABLE IF NOT EXISTS migrations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create messages table +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + publish_at DATETIME NOT NULL, + delivered_at DATETIME, + retry_at DATETIME, + retried INTEGER DEFAULT 0, + status TEXT NOT NULL, + last_errors TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for common queries +CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status); +CREATE INDEX IF NOT EXISTS idx_messages_publish_at ON messages(publish_at); \ No newline at end of file diff --git a/README.md b/README.md index e6faff0..7cac441 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,37 @@ Done isn't just another message queue service; it's a celebration of simplicity Embrace the open-source simplicity with Done. Queue up, have fun, and get it done! - ๐Ÿ“ก RESTful API, Joyful You: Manage your queues with a RESTful API that's as simple as a light switch โ€“ on, off, and awesome. -- ๐Ÿงฐ No-Frills, All Thrills: Weโ€™ve cut the fluff, leaving you with a lean, mean, message-queuing machine. +- ๐Ÿงฐ No-Frills, All Thrills: We've cut the fluff, leaving you with a lean, mean, message-queuing machine. - ๐Ÿฆ• Deno-Deploy-Powered: With its foundation in Deno, Done is as awesome as a dinosaur rocking shades. That's right, we're keeping it that cool ;) ## Features +### Storage Options + +Done supports two fantastic storage backends (because choices are awesome!): + +1. **Deno KV (Default)**: Uses Deno's built-in key-value store for all data storage - it's simple, fast, and plays beautifully with Deno Deploy! ๐Ÿฆ• +2. **Turso**: Stores data in SQLite (locally for development) or Turso's distributed SQLite service (for production) - when you need that SQL flexibility and want to scale like a boss! ๐Ÿš€ + +Each storage backend is lovingly crafted with its own specialized implementation: +- **KV Storage**: Uses a key-value model with secondary indexes for lightning-fast lookups - it's like having a perfectly organized digital filing cabinet! ๐Ÿ“ +- **Turso Storage**: Leverages SQL's native query superpowers for efficient data retrieval and manipulation - because sometimes you need that SQL muscle! ๐Ÿ’ช + +To configure the storage backend, set the following environment variables: + +``` +# Choose storage type: 'KV' or 'TURSO' +STORAGE_TYPE=KV # Default is 'KV' if not specified + +# For Turso storage +TURSO_DB_URL=https://your-db.turso.io # Optional: defaults to local SQLite file if not provided +TURSO_AUTH_TOKEN=your-auth-token # Optional: only needed for Turso cloud +``` + +For local development with Turso, you've got options! Use an in-memory database by setting `TURSO_DB_URL=:memory:` (perfect for testing - it's like having a scratch pad that vanishes when you're done) or a local file with `TURSO_DB_URL=file:turso.db` (when you want persistence without the cloud). + +Want to switch between storage types? Just update that `STORAGE_TYPE` env variable - it's like having a storage Swiss Army knife at your fingertips! ๐Ÿ”„ Whether you're team KV or team Turso, Done's got your back. ๐ŸŽฏ + ### Absolute Delay ```ts @@ -60,7 +86,7 @@ You will receive a message-id as well as the set date of callback. { "id": "msg_ytc6tbklsjmurie7ppxtqfnreh", - "publishAt": "2023-11-25T09:00:00Z" + "publish_at": "2023-11-25T09:00:00Z" } ``` __Expected callback at `2023-11-25T09:00:00Z`__ @@ -102,7 +128,7 @@ You will receive a message-id as well as the calculated date of callback. { "id": "msg_ytc6tbklsjmurie7ppxtqfnreh", - "publishAt": "2023-11-25T09:05:00Z" + "publish_at": "2023-11-25T09:05:00Z" } ``` __Expected callback at `2023-11-25T09:05:00Z`__ @@ -156,7 +182,7 @@ You will receive a message-id as well as the calculated date of callback. { "id": "msg_ytc6tbklsjmurie7ppxtqfnreh", - "publishAt": "2023-11-25T09:00:00Z" + "publish_at": "2023-11-25T09:00:00Z" } ``` __Expected callback immediate after `2023-11-25T09:00:00Z`__ diff --git a/deno.json b/deno.json index 21a8811..babdcfd 100644 --- a/deno.json +++ b/deno.json @@ -1,16 +1,22 @@ { "imports": { - "hono": "jsr:@hono/hono@4.7.4", - "zod": "npm:zod@3.24.2", - "zod-validator": "npm:@hono/zod-validator@0.4.3", - "result": "npm:neverthrow@6.1.0", - "ulid": "npm:ulid@2.3.0", + "hono": "jsr:@hono/hono@4.7.10", + "zod": "npm:zod@^3.25.0", + "zod-validator": "npm:@hono/zod-validator@0.5.0", + "result": "npm:neverthrow@8.2.0", + "ulid": "npm:ulid@3.0.0", "generate-unique-id": "npm:generate-unique-id@2.0.3", - "deep-object-diff": "npm:deep-object-diff@1.1.9" + "deep-object-diff": "npm:deep-object-diff@1.1.9", + "@libsql/client": "npm:@libsql/client@0.15.7", + "libsql-core": "npm:@libsql/core@0.15.7/api", + "libsql-node": "npm:@libsql/client@0.15.7/node", + "libsql-web": "npm:@libsql/client@0.15.7/web" }, "tasks": { "dev": "deno run -A --env=.env.local --watch --unstable-kv --unstable-cron src/main.ts", - "clean": "deno fmt -q && deno lint ./src" + "start": "deno run -A --unstable-kv --unstable-cron src/main.ts", + "clean": "deno fmt -q && deno lint ./src", + "test": "deno test -A --unstable-kv tests/" }, "lint": { "include": [ diff --git a/deno.lock b/deno.lock index 1ca59c5..96e28c8 100644 --- a/deno.lock +++ b/deno.lock @@ -1,51 +1,331 @@ { - "version": "4", + "version": "5", "specifiers": { - "jsr:@hono/hono@4.7.4": "4.7.4", - "npm:@hono/zod-validator@0.4.3": "0.4.3_hono@4.7.4_zod@3.24.2", + "jsr:@hono/hono@4.7.10": "4.7.10", + "jsr:@std/assert@*": "1.0.11", + "jsr:@std/assert@^1.0.10": "1.0.11", + "jsr:@std/assert@^1.0.13": "1.0.13", + "jsr:@std/expect@*": "1.0.16", + "jsr:@std/internal@^1.0.5": "1.0.5", + "jsr:@std/internal@^1.0.6": "1.0.7", + "jsr:@std/internal@^1.0.7": "1.0.7", + "jsr:@std/testing@*": "1.0.7", + "npm:@hono/zod-validator@0.5.0": "0.5.0_hono@4.7.10_zod@3.25.28", + "npm:@libsql/client@0.15.7": "0.15.7", + "npm:@libsql/core@0.15.7": "0.15.7", "npm:deep-object-diff@1.1.9": "1.1.9", "npm:generate-unique-id@2.0.3": "2.0.3", - "npm:neverthrow@6.1.0": "6.1.0", + "npm:neverthrow@8.2.0": "8.2.0", "npm:ts-results@3.3.0": "3.3.0", - "npm:ulid@2.3.0": "2.3.0", - "npm:zod@3.24.2": "3.24.2" + "npm:ulid@3.0.0": "3.0.0", + "npm:zod@^3.25.0": "3.25.28" }, "jsr": { - "@hono/hono@4.7.4": { - "integrity": "c03c9cbe0fbfc4e51f3fee6502a7903aa4f9ef7c2c98635607b15eee14258825" + "@hono/hono@4.7.10": { + "integrity": "e59029e252af371abe43e8e4f9a115e38974c6bd5972022372bf89f763269cc7" + }, + "@std/assert@1.0.11": { + "integrity": "2461ef3c368fe88bc60e186e7744a93112f16fd110022e113a0849e94d1c83c1", + "dependencies": [ + "jsr:@std/internal@^1.0.5" + ] + }, + "@std/assert@1.0.13": { + "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", + "dependencies": [ + "jsr:@std/internal@^1.0.6" + ] + }, + "@std/expect@1.0.16": { + "integrity": "ceeef6dda21f256a5f0f083fcc0eaca175428b523359a9b1d9b3a1df11cc7391", + "dependencies": [ + "jsr:@std/assert@^1.0.13", + "jsr:@std/internal@^1.0.7" + ] + }, + "@std/internal@1.0.5": { + "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" + }, + "@std/internal@1.0.7": { + "integrity": "39eeb5265190a7bc5d5591c9ff019490bd1f2c3907c044a11b0d545796158a0f" + }, + "@std/testing@1.0.7": { + "integrity": "aa5f0507352449064b09eff70ac1b6da3f765ee66bcc20dad9e5e433776580d5", + "dependencies": [ + "jsr:@std/assert@^1.0.10", + "jsr:@std/internal@^1.0.5" + ] } }, "npm": { - "@hono/zod-validator@0.4.3_hono@4.7.4_zod@3.24.2": { - "integrity": "sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ==", + "@hono/zod-validator@0.5.0_hono@4.7.10_zod@3.25.28": { + "integrity": "sha512-ds5bW6DCgAnNHP33E3ieSbaZFd5dkV52ZjyaXtGoR06APFrCtzAsKZxTHwOrJNBdXsi0e5wNwo5L4nVEVnJUdg==", "dependencies": [ "hono", "zod" ] }, + "@libsql/client@0.15.7": { + "integrity": "sha512-2rKekOBINDKXGwB0I5qeTDuom2944hEkWkjN8O41j95/HRKP+3sk/fq6/PoPJSuwY3pgWAS8vyby+FgOyPnIVQ==", + "dependencies": [ + "@libsql/core", + "@libsql/hrana-client", + "js-base64", + "libsql", + "promise-limit" + ] + }, + "@libsql/core@0.15.7": { + "integrity": "sha512-hW1++8iKAEnb7Y3EZ2zXRR+1K0MKdRT7SLaNgFkfwz6CmiIBY3sYN7VSftNS7IR6xKRvFBpoz10CC63NoFGkaQ==", + "dependencies": [ + "js-base64" + ] + }, + "@libsql/darwin-arm64@0.4.7": { + "integrity": "sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@libsql/darwin-arm64@0.5.11": { + "integrity": "sha512-Av4+H8VypNZdbRbDKu5ogoCBHOdYh2Vx6iO7+0SACjcgnpqjnGL59lJUuX3fmV48VI6al1xORYJVApo//B5iqA==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@libsql/darwin-x64@0.4.7": { + "integrity": "sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@libsql/darwin-x64@0.5.11": { + "integrity": "sha512-+BXozvOKhwbye16itymY2YXeHOcIeZGORdJK2prfXA7Q2HR4/dRdUirR1o/koxxxG616uiWlAVj5WJ0j2IWkQA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@libsql/hrana-client@0.7.0": { + "integrity": "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==", + "dependencies": [ + "@libsql/isomorphic-fetch", + "@libsql/isomorphic-ws", + "js-base64", + "node-fetch" + ] + }, + "@libsql/isomorphic-fetch@0.3.1": { + "integrity": "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==" + }, + "@libsql/isomorphic-ws@0.1.5": { + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "dependencies": [ + "@types/ws", + "ws" + ] + }, + "@libsql/linux-arm-gnueabihf@0.5.11": { + "integrity": "sha512-znsVKbKgOerCNkIY0HjtvkioVGLskmGXZodZn3TMDRTmn1PIUt7/dnxU5moKMdKa1hKDSOC52dqF77nAdkn4UA==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@libsql/linux-arm-musleabihf@0.5.11": { + "integrity": "sha512-l4gJY6AvhQ4fUJRpjph3AW6pbiAUcVxJUH0oM5Pf/GnA9acpaDgLtle2hWMz16BSncg/Jl2jVpaJuyJsJ9E7YA==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@libsql/linux-arm64-gnu@0.4.7": { + "integrity": "sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@libsql/linux-arm64-gnu@0.5.11": { + "integrity": "sha512-axXEenVUnSKR25g0iqL/OH4z4qrPBNwdBhjTWZr613L9tnboDPAioP1kVEy77nN8C8CL/dyXh5X4vKuIwHrQpQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@libsql/linux-arm64-musl@0.4.7": { + "integrity": "sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@libsql/linux-arm64-musl@0.5.11": { + "integrity": "sha512-Pzz9dm2D78PQpy3pYKbvzBBOwdjg9c3yoQSu5QQQCGL4J5e1bZpa/p6Z3BoYBlvmdo1V36ljS6N4hRir/rnCxg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@libsql/linux-x64-gnu@0.4.7": { + "integrity": "sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@libsql/linux-x64-gnu@0.5.11": { + "integrity": "sha512-DxOU0MqG7soKZFVzOo7Zot5qDajZjjOgjf/sOjeJf/aeRBr3KkKiwgWKnmjDhuhitahqc8Nu2D92/dsAuDHJsA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@libsql/linux-x64-musl@0.4.7": { + "integrity": "sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@libsql/linux-x64-musl@0.5.11": { + "integrity": "sha512-uRou4r+PiDA616t2USnsjbot88ennTrwKqhVUY7S6LTPI3RiKizZg6YESCwhzofPtk8Ualp/hMQGTGSoW9DUKw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@libsql/win32-x64-msvc@0.4.7": { + "integrity": "sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@libsql/win32-x64-msvc@0.5.11": { + "integrity": "sha512-NES0P2pyx5XjveTYotTG03eoJwx0haJBYWXfqmcPLmbQ5u03Qmd7rxhLfWDdIRj4PrdhVProwdB0FA82ryLcKQ==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@neon-rs/load@0.0.4": { + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==" + }, + "@rollup/rollup-linux-x64-gnu@4.34.8": { + "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@types/node@22.15.15": { + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": [ + "undici-types" + ] + }, + "@types/ws@8.18.1": { + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dependencies": [ + "@types/node" + ] + }, + "data-uri-to-buffer@4.0.1": { + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" + }, "deep-object-diff@1.1.9": { "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==" }, + "detect-libc@2.0.2": { + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==" + }, + "fetch-blob@3.2.0": { + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dependencies": [ + "node-domexception", + "web-streams-polyfill" + ] + }, + "formdata-polyfill@4.0.10": { + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": [ + "fetch-blob" + ] + }, "generate-unique-id@2.0.3": { "integrity": "sha512-oADhkjv6nsiHNJNa+kCe/h6vqgooEPmASHU40hWGbhDODb/xKp5ej7l+7BNs3bQ/v8DCbaVJ42//kN2umQfr6A==" }, - "hono@4.7.4": { - "integrity": "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg==" + "hono@4.7.10": { + "integrity": "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==" + }, + "js-base64@3.7.7": { + "integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==" + }, + "libsql@0.5.11": { + "integrity": "sha512-P2xY1nL2Jl7oM75LcguAEYqouVcevWhLWT8RU/p9ldaqQx5s/chF9t5ZFXPWP0x9myQQ4SguRqPO+FqdnCzKQg==", + "dependencies": [ + "@neon-rs/load", + "detect-libc" + ], + "optionalDependencies": [ + "@libsql/darwin-arm64@0.5.11", + "@libsql/darwin-x64@0.5.11", + "@libsql/linux-arm-gnueabihf", + "@libsql/linux-arm-musleabihf", + "@libsql/linux-arm64-gnu@0.5.11", + "@libsql/linux-arm64-musl@0.5.11", + "@libsql/linux-x64-gnu@0.5.11", + "@libsql/linux-x64-musl@0.5.11", + "@libsql/win32-x64-msvc@0.5.11" + ], + "os": ["darwin", "linux", "win32"], + "cpu": ["x64", "arm64", "wasm32", "arm"] + }, + "neverthrow@8.2.0": { + "integrity": "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==", + "optionalDependencies": [ + "@rollup/rollup-linux-x64-gnu" + ] + }, + "node-domexception@1.0.0": { + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": true + }, + "node-fetch@3.3.2": { + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": [ + "data-uri-to-buffer", + "fetch-blob", + "formdata-polyfill" + ] }, - "neverthrow@6.1.0": { - "integrity": "sha512-xNbNjp/6M5vUV+mststgneJN9eJeJCDSYSBTaf3vxgvcKooP+8L0ATFpM8DGfmH7UWKJeoa24Qi33tBP9Ya3zA==" + "promise-limit@2.7.0": { + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==" }, "ts-results@3.3.0": { "integrity": "sha512-FWqxGX2NHp5oCyaMd96o2y2uMQmSu8Dey6kvyuFdRJ2AzfmWo3kWa4UsPlCGlfQ/qu03m09ZZtppMoY8EMHuiA==" }, - "ulid@2.3.0": { - "integrity": "sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw==" + "ulid@3.0.0": { + "integrity": "sha512-yvZYdXInnJve6LdlPIuYmURdS2NP41ZoF4QW7SXwbUKYt53+0eDAySO+rGSvM2O/ciuB/G+8N7GQrZ1mCJpuqw==", + "bin": true }, - "zod@3.24.2": { - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==" + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "web-streams-polyfill@3.3.3": { + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" + }, + "ws@8.18.2": { + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==" + }, + "zod@3.25.28": { + "integrity": "sha512-/nt/67WYKnr5by3YS7LroZJbtcCBurDKKPBPWWzaxvVCGuG/NOsiKkrjoOhI8mJ+SQUXEbUzeB3S+6XDUEEj7Q==" } }, + "redirects": { + "https://deno.land/std/assert/mod.ts": "https://deno.land/std@0.224.0/assert/mod.ts", + "https://deno.land/std/testing/asserts.ts": "https://deno.land/std@0.224.0/testing/asserts.ts", + "https://deno.land/std/testing/bdd.ts": "https://deno.land/std@0.224.0/testing/bdd.ts" + }, "remote": { + "https://deno.land/std@0.200.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", + "https://deno.land/std@0.200.0/assert/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.200.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.200.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", + "https://deno.land/std@0.200.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", + "https://deno.land/std@0.200.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", + "https://deno.land/std@0.200.0/assert/assert_equals.ts": "d8ec8a22447fbaf2fc9d7c3ed2e66790fdb74beae3e482855d75782218d68227", + "https://deno.land/std@0.200.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", + "https://deno.land/std@0.200.0/assert/assert_false.ts": "a9962749f4bf5844e3fa494257f1de73d69e4fe0e82c34d0099287552163a2dc", + "https://deno.land/std@0.200.0/assert/assert_instance_of.ts": "09fd297352a5b5bbb16da2b5e1a0d8c6c44da5447772648622dcc7df7af1ddb8", + "https://deno.land/std@0.200.0/assert/assert_is_error.ts": "b4eae4e5d182272efc172bf28e2e30b86bb1650cd88aea059e5d2586d4160fb9", + "https://deno.land/std@0.200.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", + "https://deno.land/std@0.200.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", + "https://deno.land/std@0.200.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", + "https://deno.land/std@0.200.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", + "https://deno.land/std@0.200.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", + "https://deno.land/std@0.200.0/assert/assert_object_match.ts": "d8fc2867cfd92eeacf9cea621e10336b666de1874a6767b5ec48988838370b54", + "https://deno.land/std@0.200.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", + "https://deno.land/std@0.200.0/assert/assert_strict_equals.ts": "b1f538a7ea5f8348aeca261d4f9ca603127c665e0f2bbfeb91fa272787c87265", + "https://deno.land/std@0.200.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", + "https://deno.land/std@0.200.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", + "https://deno.land/std@0.200.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.200.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", + "https://deno.land/std@0.200.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", + "https://deno.land/std@0.200.0/assert/mod.ts": "08d55a652c22c5da0215054b21085cec25a5da47ce4a6f9de7d9ad36df35bdee", + "https://deno.land/std@0.200.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", + "https://deno.land/std@0.200.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", + "https://deno.land/std@0.200.0/fmt/colors.ts": "a7eecffdf3d1d54db890723b303847b6e0a1ab4b528ba6958b8f2e754cf1b3bc", "https://deno.land/std@0.205.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", "https://deno.land/std@0.205.0/assert/_diff.ts": "58e1461cc61d8eb1eacbf2a010932bf6a05b79344b02ca38095f9b805795dc48", "https://deno.land/std@0.205.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", @@ -78,6 +358,74 @@ "https://deno.land/std@0.205.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", "https://deno.land/std@0.205.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", "https://deno.land/std@0.205.0/fmt/colors.ts": "c51c4642678eb690dcf5ffee5918b675bf01a33fba82acf303701ae1a4f8c8d9", + "https://deno.land/std@0.217.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.217.0/assert/_diff.ts": "dcc63d94ca289aec80644030cf88ccbf7acaa6fbd7b0f22add93616b36593840", + "https://deno.land/std@0.217.0/assert/_format.ts": "0ba808961bf678437fb486b56405b6fefad2cf87b5809667c781ddee8c32aff4", + "https://deno.land/std@0.217.0/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5", + "https://deno.land/std@0.217.0/assert/assert_almost_equals.ts": "8b96b7385cc117668b0720115eb6ee73d04c9bcb2f5d2344d674918c9113688f", + "https://deno.land/std@0.217.0/assert/assert_array_includes.ts": "1688d76317fd45b7e93ef9e2765f112fdf2b7c9821016cdfb380b9445374aed1", + "https://deno.land/std@0.217.0/assert/assert_equals.ts": "4497c56fe7d2993b0d447926702802fc0becb44e319079e8eca39b482ee01b4e", + "https://deno.land/std@0.217.0/assert/assert_exists.ts": "24a7bf965e634f909242cd09fbaf38bde6b791128ece08e33ab08586a7cc55c9", + "https://deno.land/std@0.217.0/assert/assert_false.ts": "6f382568e5128c0f855e5f7dbda8624c1ed9af4fcc33ef4a9afeeedcdce99769", + "https://deno.land/std@0.217.0/assert/assert_greater.ts": "4945cf5729f1a38874d7e589e0fe5cc5cd5abe5573ca2ddca9d3791aa891856c", + "https://deno.land/std@0.217.0/assert/assert_greater_or_equal.ts": "573ed8823283b8d94b7443eb69a849a3c369a8eb9666b2d1db50c33763a5d219", + "https://deno.land/std@0.217.0/assert/assert_instance_of.ts": "72dc1faff1e248692d873c89382fa1579dd7b53b56d52f37f9874a75b11ba444", + "https://deno.land/std@0.217.0/assert/assert_is_error.ts": "6596f2b5ba89ba2fe9b074f75e9318cda97a2381e59d476812e30077fbdb6ed2", + "https://deno.land/std@0.217.0/assert/assert_less.ts": "2b4b3fe7910f65f7be52212f19c3977ecb8ba5b2d6d0a296c83cde42920bb005", + "https://deno.land/std@0.217.0/assert/assert_less_or_equal.ts": "b93d212fe669fbde959e35b3437ac9a4468f2e6b77377e7b6ea2cfdd825d38a0", + "https://deno.land/std@0.217.0/assert/assert_match.ts": "ec2d9680ed3e7b9746ec57ec923a17eef6d476202f339ad91d22277d7f1d16e1", + "https://deno.land/std@0.217.0/assert/assert_not_equals.ts": "ac86413ab70ffb14fdfc41740ba579a983fe355ba0ce4a9ab685e6b8e7f6a250", + "https://deno.land/std@0.217.0/assert/assert_not_instance_of.ts": "8f720d92d83775c40b2542a8d76c60c2d4aeddaf8713c8d11df8984af2604931", + "https://deno.land/std@0.217.0/assert/assert_not_match.ts": "b4b7c77f146963e2b673c1ce4846473703409eb93f5ab0eb60f6e6f8aeffe39f", + "https://deno.land/std@0.217.0/assert/assert_not_strict_equals.ts": "da0b8ab60a45d5a9371088378e5313f624799470c3b54c76e8b8abeec40a77be", + "https://deno.land/std@0.217.0/assert/assert_object_match.ts": "e85e5eef62a56ce364c3afdd27978ccab979288a3e772e6855c270a7b118fa49", + "https://deno.land/std@0.217.0/assert/assert_rejects.ts": "e9e0c8d9c3e164c7ac962c37b3be50577c5a2010db107ed272c4c1afb1269f54", + "https://deno.land/std@0.217.0/assert/assert_strict_equals.ts": "0425a98f70badccb151644c902384c12771a93e65f8ff610244b8147b03a2366", + "https://deno.land/std@0.217.0/assert/assert_string_includes.ts": "dfb072a890167146f8e5bdd6fde887ce4657098e9f71f12716ef37f35fb6f4a7", + "https://deno.land/std@0.217.0/assert/assert_throws.ts": "edddd86b39606c342164b49ad88dd39a26e72a26655e07545d172f164b617fa7", + "https://deno.land/std@0.217.0/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8", + "https://deno.land/std@0.217.0/assert/equal.ts": "fae5e8a52a11d3ac694bbe1a53e13a7969e3f60791262312e91a3e741ae519e2", + "https://deno.land/std@0.217.0/assert/fail.ts": "f310e51992bac8e54f5fd8e44d098638434b2edb802383690e0d7a9be1979f1c", + "https://deno.land/std@0.217.0/assert/mod.ts": "325df8c0683ad83a873b9691aa66b812d6275fc9fec0b2d180ac68a2c5efed3b", + "https://deno.land/std@0.217.0/assert/unimplemented.ts": "47ca67d1c6dc53abd0bd729b71a31e0825fc452dbcd4fde4ca06789d5644e7fd", + "https://deno.land/std@0.217.0/assert/unreachable.ts": "38cfecb95d8b06906022d2f9474794fca4161a994f83354fd079cac9032b5145", + "https://deno.land/std@0.217.0/fmt/colors.ts": "d239d84620b921ea520125d778947881f62c50e78deef2657073840b8af9559a", + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", + "https://deno.land/std@0.224.0/testing/_test_suite.ts": "f10a8a6338b60c403f07a76f3f46bdc9f1e1a820c0a1decddeb2949f7a8a0546", + "https://deno.land/std@0.224.0/testing/asserts.ts": "d0cdbabadc49cc4247a50732ee0df1403fdcd0f95360294ad448ae8c240f3f5c", + "https://deno.land/std@0.224.0/testing/bdd.ts": "3e4de4ff6d8f348b5574661cef9501b442046a59079e201b849d0e74120d476b", "https://deno.land/x/hono@v3.9.0/client/client.ts": "ff340f58041203879972dd368b011ed130c66914f789826610869a90603406bf", "https://deno.land/x/hono@v3.9.0/client/index.ts": "3ff4cf246f3543f827a85a2c84d66a025ac350ee927613629bda47e854bfb7ba", "https://deno.land/x/hono@v3.9.0/client/types.ts": "52c66cbe74540e1811259a48c30622ac915666196eb978092d166435cbc15213", @@ -203,13 +551,15 @@ }, "workspace": { "dependencies": [ - "jsr:@hono/hono@4.7.4", - "npm:@hono/zod-validator@0.4.3", + "jsr:@hono/hono@4.7.10", + "npm:@hono/zod-validator@0.5.0", + "npm:@libsql/client@0.15.7", + "npm:@libsql/core@0.15.7", "npm:deep-object-diff@1.1.9", "npm:generate-unique-id@2.0.3", - "npm:neverthrow@6.1.0", - "npm:ulid@2.3.0", - "npm:zod@3.24.2" + "npm:neverthrow@8.2.0", + "npm:ulid@3.0.0", + "npm:zod@^3.25.0" ] } -} \ No newline at end of file +} diff --git a/docs/bruno-collection/Admin/Fetch Log By Message ID.bru b/docs/bruno-collection/Admin/Fetch Log By Message ID.bru index ca7f564..feeddac 100644 --- a/docs/bruno-collection/Admin/Fetch Log By Message ID.bru +++ b/docs/bruno-collection/Admin/Fetch Log By Message ID.bru @@ -7,5 +7,9 @@ meta { get { url: {{url}}/v1/admin/log/msg_01HFYG8SXNHT22BESVHY5F0JA7 body: none - auth: none + auth: bearer +} + +auth:bearer { + token: {{token}} } diff --git a/docs/bruno-collection/Admin/Fetch Logs.bru b/docs/bruno-collection/Admin/Fetch Logs.bru index 2f89e6e..b51f706 100644 --- a/docs/bruno-collection/Admin/Fetch Logs.bru +++ b/docs/bruno-collection/Admin/Fetch Logs.bru @@ -7,5 +7,9 @@ meta { get { url: {{url}}/v1/admin/logs body: none - auth: none + auth: bearer +} + +auth:bearer { + token: {{token}} } diff --git a/docs/bruno-collection/Admin/Fetch Stats.bru b/docs/bruno-collection/Admin/Fetch Stats.bru index d14e091..92a11fb 100644 --- a/docs/bruno-collection/Admin/Fetch Stats.bru +++ b/docs/bruno-collection/Admin/Fetch Stats.bru @@ -7,5 +7,9 @@ meta { get { url: {{url}}/v1/admin/stats body: none - auth: none + auth: bearer +} + +auth:bearer { + token: {{token}} } diff --git a/docs/bruno-collection/Admin/Fetch raw data.bru b/docs/bruno-collection/Admin/Fetch raw data.bru index 3205a84..b8b9611 100644 --- a/docs/bruno-collection/Admin/Fetch raw data.bru +++ b/docs/bruno-collection/Admin/Fetch raw data.bru @@ -7,5 +7,9 @@ meta { get { url: {{url}}/v1/admin/raw body: none - auth: none + auth: bearer +} + +auth:bearer { + token: {{token}} } diff --git a/docs/bruno-collection/Admin/Reset all data.bru b/docs/bruno-collection/Admin/Reset all data.bru index 12538f4..e4bccfa 100644 --- a/docs/bruno-collection/Admin/Reset all data.bru +++ b/docs/bruno-collection/Admin/Reset all data.bru @@ -7,5 +7,9 @@ meta { delete { url: {{url}}/v1/admin/reset body: none - auth: none + auth: bearer +} + +auth:bearer { + token: {{token}} } diff --git a/docs/bruno-collection/Admin/Reset logs only.bru b/docs/bruno-collection/Admin/Reset logs only.bru new file mode 100644 index 0000000..7057fe9 --- /dev/null +++ b/docs/bruno-collection/Admin/Reset logs only.bru @@ -0,0 +1,15 @@ +meta { + name: Reset logs only + type: http + seq: 6 +} + +delete { + url: {{url}}/v1/admin/reset/logs + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} \ No newline at end of file diff --git a/docs/bruno-collection/Messages/Create (absolute).bru b/docs/bruno-collection/Messages/Create (absolute).bru index d6dbb81..3da1f36 100644 --- a/docs/bruno-collection/Messages/Create (absolute).bru +++ b/docs/bruno-collection/Messages/Create (absolute).bru @@ -7,13 +7,17 @@ meta { post { url: {{url}}/v1/messages/https://done.gotrequests.com/some-user-path/ body: json - auth: none + auth: bearer } headers { Done-Not-Before: 1700794800 } +auth:bearer { + token: {{token}} +} + body:json { { "invoice_id": "invoice_1234567890" diff --git a/docs/bruno-collection/Messages/Create (immediately).bru b/docs/bruno-collection/Messages/Create (immediately).bru index 90789e5..c6d7c82 100644 --- a/docs/bruno-collection/Messages/Create (immediately).bru +++ b/docs/bruno-collection/Messages/Create (immediately).bru @@ -7,5 +7,9 @@ meta { post { url: {{url}}/v1/messages/https://done.gotrequests.com/some-user-path/ body: none - auth: none + auth: bearer +} + +auth:bearer { + token: {{token}} } diff --git a/docs/bruno-collection/Messages/Create (relative).bru b/docs/bruno-collection/Messages/Create (relative).bru index 4f847d0..097480d 100644 --- a/docs/bruno-collection/Messages/Create (relative).bru +++ b/docs/bruno-collection/Messages/Create (relative).bru @@ -7,7 +7,7 @@ meta { post { url: {{url}}/v1/messages/https://done.gotrequests.com/some-user-path/ body: none - auth: none + auth: bearer } headers { @@ -15,3 +15,7 @@ headers { Done-Forward-X-Foo: Bar Done-Failure-Callback: https://done.gotrequests.com/failed-requests } + +auth:bearer { + token: {{token}} +} diff --git a/docs/bruno-collection/Messages/Fetch By Status.bru b/docs/bruno-collection/Messages/Fetch By Status.bru index d2c7a11..32bd723 100644 --- a/docs/bruno-collection/Messages/Fetch By Status.bru +++ b/docs/bruno-collection/Messages/Fetch By Status.bru @@ -7,5 +7,9 @@ meta { get { url: {{url}}/v1/messages/by-status/sent body: none - auth: none + auth: bearer +} + +auth:bearer { + token: {{token}} } diff --git a/docs/bruno-collection/Messages/Fetch.bru b/docs/bruno-collection/Messages/Fetch.bru index 84f1975..3c17ead 100644 --- a/docs/bruno-collection/Messages/Fetch.bru +++ b/docs/bruno-collection/Messages/Fetch.bru @@ -7,5 +7,9 @@ meta { get { url: {{url}}/v1/messages/msg_hn82g39y6c4xn7227qv06d86fo body: none - auth: none + auth: bearer +} + +auth:bearer { + token: {{token}} } diff --git a/migrations/000_create_migrations_table.sql b/migrations/000_create_migrations_table.sql new file mode 100644 index 0000000..5867f1d --- /dev/null +++ b/migrations/000_create_migrations_table.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS migrations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/001_create_messages_table.sql b/migrations/001_create_messages_table.sql new file mode 100644 index 0000000..d741ea4 --- /dev/null +++ b/migrations/001_create_messages_table.sql @@ -0,0 +1,17 @@ +DROP TABLE IF EXISTS messages; + +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + publish_at TEXT NOT NULL, + delivered_at TEXT, + retry_at TEXT, + retried INTEGER DEFAULT 0, + status TEXT NOT NULL, + last_errors TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status); +CREATE INDEX IF NOT EXISTS idx_messages_publish_at ON messages(publish_at); \ No newline at end of file diff --git a/migrations/002_create_logs_table.sql b/migrations/002_create_logs_table.sql new file mode 100644 index 0000000..67a642b --- /dev/null +++ b/migrations/002_create_logs_table.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS logs ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + object TEXT NOT NULL, + message_id TEXT NOT NULL, + before_data TEXT NOT NULL, -- JSON string of before state + after_data TEXT NOT NULL, -- JSON string of after state + created_at INTEGER NOT NULL, + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE +); + +-- Index for efficient lookups by message_id +CREATE INDEX IF NOT EXISTS idx_logs_message_id ON logs(message_id); + +-- Index for efficient lookups by type +CREATE INDEX IF NOT EXISTS idx_logs_type ON logs(type); + +-- Index for efficient lookups by created_at +CREATE INDEX IF NOT EXISTS idx_logs_created_at ON logs(created_at); \ No newline at end of file diff --git a/src/interfaces/logs-store-interface.ts b/src/interfaces/logs-store-interface.ts new file mode 100644 index 0000000..993881e --- /dev/null +++ b/src/interfaces/logs-store-interface.ts @@ -0,0 +1,10 @@ +import z from 'zod'; +import { LogMessageDataSchema, LogMessageModelSchema } from '../schemas/log-schema.ts'; + +export interface LogsStoreInterface { + getStoreName(): string; + getModelIdPrefix(): string; + buildModelId(): string; + buildModelIdWithPrefix(): string; + create(data: z.infer, options?: { withId: string }): Promise>; +} diff --git a/src/interfaces/messages-store-interface.ts b/src/interfaces/messages-store-interface.ts new file mode 100644 index 0000000..a24024d --- /dev/null +++ b/src/interfaces/messages-store-interface.ts @@ -0,0 +1,16 @@ +import { Result } from 'result'; +import { MESSAGE_STATUS, MessageModel, MessageReceivedData } from '../stores/kv/kv-message-model.ts'; + +export interface MessagesStoreInterface { + getStoreName(): string; + getModelIdPrefix(): string; + buildModelId(): string; + buildModelIdWithPrefix(): string; + createFromReceivedData(data: MessageReceivedData): Promise>; + create(message: MessageModel): Promise>; + fetchOne(id: string): Promise>; + fetchByStatus(status: MESSAGE_STATUS): Promise>; + fetchByDate(date: Date): Promise>; + update(id: string, message: Partial): Promise>; + delete(id: string): Promise>; +} diff --git a/src/main.ts b/src/main.ts index decdd8e..24ef71e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,18 +1,41 @@ -import { Hono } from 'hono'; -import { bearerAuth } from 'hono/bearer-auth'; +import { Context } from 'hono'; import { MessageStateManager } from './managers/message-state-manager.ts'; -import { adminRoutes } from './routes/admin-routes.ts'; -import { messageRoutes } from './routes/message-routes.ts'; -import { MESSAGE_STATUS } from './stores/message-model.ts'; -import { MessagesStore } from './stores/messages-store.ts'; +import { KvAdminRoutes } from './routes/kv-admin-routes.ts'; +import { MessageRoutes } from './routes/message-routes.ts'; +import { SystemRoutes } from './routes/system-routes.ts'; +import { TursoAdminRoutes } from './routes/turso-admin-routes.ts'; +import { AuthMiddleware } from './services/auth-middleware.ts'; +import { SystemMessage } from './services/storage/kv-store.ts'; +import { SqliteStore } from './services/storage/sqlite-store.ts'; +import { StoreFactory } from './stores/store-factory.ts'; +import { Env } from './utils/env.ts'; +import { Routes } from './utils/routes.ts'; import { Security } from './utils/security.ts'; -import { SystemMessage } from './utils/store.ts'; - -export const VERSION = 'v1'; +import { VERSION_STRING } from './version.ts'; +// Initialize stores const kv = await Deno.openKv(); -const router = new Hono(); -router.use(`/${VERSION}/*`, bearerAuth({ token: Deno.env.get('AUTH_TOKEN') || Security.generateAuthToken() })); +const sqlite = await SqliteStore.create(Deno.env.get('TURSO_DB_URL')!, Deno.env.get('TURSO_DB_AUTH_TOKEN')); +const messageStore = StoreFactory.getMessagesStore({ kv, sqlite }); +const logsStore = StoreFactory.getLogsStore({ kv, sqlite }); + +// Initialize Hono with Routes utility +const hono = Routes.initHono(); + +// Add middleware +hono.use( + `/${VERSION_STRING}/*`, + AuthMiddleware.bearer({ + token: Env.get('AUTH_TOKEN') || Security.generateAuthToken(), + skipPaths: [`/${VERSION_STRING}/system/ping`], + }), +); + +// Add error handler +hono.onError((error: Error, c: Context) => { + console.error(error); + return c.json({ error: 'An error occurred. We have been notified.' }, 500); +}); // ############################################## // add cron @@ -20,15 +43,14 @@ router.use(`/${VERSION}/*`, bearerAuth({ token: Deno.env.get('AUTH_TOKEN') || Se Deno.cron('enqueue todays messages', '0 0 * * *', async () => { console.log(`[${new Date().toISOString()}] cron: check for todays messages`); - const store = new MessagesStore(kv); - const messagesResult = await store.fetchByDate(new Date()); + const messagesResult = await messageStore.fetchByDate(new Date()); if (messagesResult.isOk()) { const messages = messagesResult.value; for (const message of messages) { - if (message.status === MESSAGE_STATUS.CREATED) { + if (message.status === 'CREATED') { console.debug(`[${new Date().toISOString()}] cron: deliver message ${message.id}`); - await store.update(message.id, { status: MESSAGE_STATUS.QUEUED }); + await messageStore.update(message.id, { status: 'QUEUED' }); } } } @@ -40,15 +62,26 @@ Deno.cron('enqueue todays messages', '0 0 * * *', async () => { kv.listenQueue(async (incoming: unknown) => { const message = incoming as SystemMessage; console.log(`[${new Date().toISOString()}] received message ${message.id} with type ${message.type}`); - await new MessageStateManager(kv).handleState(message); + await new MessageStateManager(kv, messageStore).handleState(message); }); // ############################################ // routes -messageRoutes(router, kv); -adminRoutes(router, kv); +// Create admin routes based on storage type +const storageType = StoreFactory.getStorageType(); +const adminRoutes = storageType === 'KV' ? new KvAdminRoutes(messageStore, logsStore, kv) : new TursoAdminRoutes(messageStore, logsStore, sqlite); + +const routes = [ + new MessageRoutes(kv, messageStore), + adminRoutes, + new SystemRoutes(), +]; + +for (const route of routes) { + hono.route(route.getBasePath(VERSION_STRING), route.getRoutes()); +} // ############################################ -Deno.serve({ port: 3001 }, router.fetch); +Deno.serve({ port: 3001 }, hono.fetch); diff --git a/src/managers/message-state-manager.ts b/src/managers/message-state-manager.ts index 3a9a4ce..14c173f 100644 --- a/src/managers/message-state-manager.ts +++ b/src/managers/message-state-manager.ts @@ -1,13 +1,13 @@ -import { MESSAGE_STATUS, MessageModel, MessageReceivedData } from '../stores/message-model.ts'; -import { MessagesStore } from '../stores/messages-store.ts'; +import { MessagesStoreInterface } from '../interfaces/messages-store-interface.ts'; +import { KvStore, SYSTEM_MESSAGE_TYPE, SystemMessage } from '../services/storage/kv-store.ts'; +import { MessageModel, MessageReceivedData } from '../stores/kv/kv-message-model.ts'; import { Dates } from '../utils/dates.ts'; import { Http } from '../utils/http.ts'; -import { Store, SYSTEM_MESSAGE_TYPE, SystemMessage } from '../utils/store.ts'; const RETRY_DELAY_MINUTES = 1; export class MessageStateManager { - constructor(private kv: Deno.Kv) {} + constructor(private kv: Deno.Kv, private messageStore: MessagesStoreInterface) {} async handleState(message: SystemMessage) { const model = this.getModelFromMessage(message); @@ -15,33 +15,33 @@ export class MessageStateManager { // handle message type switch (message.type) { case SYSTEM_MESSAGE_TYPE.MESSAGE_RECEIVED: - await this.getStore().createFromReceivedData(message.data as MessageReceivedData); + await this.messageStore.createFromReceivedData(message.data as MessageReceivedData); return; case SYSTEM_MESSAGE_TYPE.MESSAGE_QUEUED: case SYSTEM_MESSAGE_TYPE.MESSAGE_RETRY: - await this.getStore().update(model.id, { status: MESSAGE_STATUS.DELIVER }); + await this.messageStore.update(model.id, { status: 'DELIVER' }); return; default: } // handle model state switch (model.status) { - case MESSAGE_STATUS.CREATED: + case 'CREATED': await this.stateCreated(model); break; - case MESSAGE_STATUS.QUEUED: + case 'QUEUED': await this.stateQueued(model); break; - case MESSAGE_STATUS.DELIVER: + case 'DELIVER': await this.stateDeliver(model); break; - case MESSAGE_STATUS.SENT: + case 'SENT': await this.stateSent(model); break; - case MESSAGE_STATUS.RETRY: + case 'RETRY': await this.stateRetry(model); break; - case MESSAGE_STATUS.DLQ: + case 'DLQ': await this.stateDLQ(model); break; default: @@ -52,30 +52,30 @@ export class MessageStateManager { const today = new Date(); // send now - if (model.publishAt.getTime() < today.getTime()) { - await this.getStore().update(model.id, { status: MESSAGE_STATUS.DELIVER }); + if (model.publish_at.getTime() < today.getTime()) { + await this.messageStore.update(model.id, { status: 'DELIVER' }); return; } const todayDateOnly = Dates.getDateOnly(today); - const publishAtDateOnly = Dates.getDateOnly(model.publishAt); + const publishAtDateOnly = Dates.getDateOnly(model.publish_at); // queue for later if (todayDateOnly === publishAtDateOnly) { - await this.getStore().update(model.id, { status: MESSAGE_STATUS.QUEUED }); + await this.messageStore.update(model.id, { status: 'QUEUED' }); } } private async stateQueued(model: MessageModel) { const today = new Date(); - const delay = model.publishAt.getTime() - today.getTime(); + const delay = model.publish_at.getTime() - today.getTime(); const message: SystemMessage = { - id: Store.buildLogId(), + id: KvStore.buildLogId(), type: SYSTEM_MESSAGE_TYPE.MESSAGE_QUEUED, - object: this.getStore().getStoreName(), + object: this.messageStore.getStoreName(), data: model, - createdAt: new Date(), + created_at: new Date(), }; await this.kv.enqueue(message, { delay }); @@ -84,14 +84,14 @@ export class MessageStateManager { private async stateRetry(model: MessageModel) { console.debug(`[${new Date().toISOString()}] retry message ${model.id}`); - const delay = model.retryAt ? model.retryAt.getTime() - new Date().getTime() : 0; // retryAt or immediately + const delay = model.retry_at ? model.retry_at.getTime() - new Date().getTime() : 0; // retryAt or immediately const message: SystemMessage = { - id: Store.buildLogId(), + id: KvStore.buildLogId(), type: SYSTEM_MESSAGE_TYPE.MESSAGE_RETRY, - object: this.getStore().getStoreName(), + object: this.messageStore.getStoreName(), data: model, - createdAt: new Date(), + created_at: new Date(), }; await this.kv.enqueue(message, { delay }); @@ -114,47 +114,47 @@ export class MessageStateManager { const response = await fetch(model.payload.url, options); if (response.status === 200 || response.status === 201) { - await this.getStore().update(model.id, { deliveredAt: new Date(), status: MESSAGE_STATUS.SENT }); + await this.messageStore.update(model.id, { delivered_at: new Date(), status: 'SENT' }); return; } responseStatus = response.status; lastDeliveryErrorMessage = 'invalid response status'; - } catch (error: unknown) { - lastDeliveryErrorMessage = error instanceof Error ? error.message : String(error); + } catch (error) { + lastDeliveryErrorMessage = error instanceof Error ? error.message : 'unknown error'; } - if (!model.lastErrors) { - model.lastErrors = []; + if (!model.last_errors) { + model.last_errors = []; } - model.lastErrors.push({ + model.last_errors.push({ url: model.payload.url, status: responseStatus, message: lastDeliveryErrorMessage, - createdAt: new Date(), + created_at: new Date(), }); // retry if (model.retried !== undefined && model.retried < 3) { const delay = 1000 * 60 * RETRY_DELAY_MINUTES; - await this.getStore().update(model.id, { - lastErrors: model.lastErrors, + await this.messageStore.update(model.id, { + last_errors: model.last_errors, retried: model.retried + 1, - retryAt: new Date(new Date().getTime() + delay), - status: MESSAGE_STATUS.RETRY, + retry_at: new Date(new Date().getTime() + delay), + status: 'RETRY', }); return; } // send to DLQ - await this.getStore().update(model.id, { lastErrors: model.lastErrors, status: MESSAGE_STATUS.DLQ }); + await this.messageStore.update(model.id, { last_errors: model.last_errors, status: 'DLQ' }); } private stateSent(model: MessageModel) { - console.debug(`[${new Date().toISOString()}] sent message ${model.id} to ${model.payload.url} at ${model.deliveredAt?.toISOString()}`); + console.debug(`[${new Date().toISOString()}] sent message ${model.id} to ${model.payload.url} at ${model.delivered_at?.toISOString()}`); } private async stateDLQ(model: MessageModel) { @@ -197,8 +197,4 @@ export class MessageStateManager { return message.data as Model; } - - private getStore() { - return new MessagesStore(this.kv); - } } diff --git a/src/routes/admin-routes.ts b/src/routes/admin-routes.ts deleted file mode 100644 index 9fd4947..0000000 --- a/src/routes/admin-routes.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Context, Hono } from 'hono'; -import { VERSION } from '../main.ts'; -import { MessageModel } from '../stores/message-model.ts'; -import { Store } from '../utils/store.ts'; - -export const adminRoutes = (router: Hono, kv: Deno.Kv) => { - const baseRouter = router.basePath(`/${VERSION}/admin`); - - baseRouter.get(`/stats`, async (ctx: Context) => { - const stats: Record = {}; - const entries = kv.list({ prefix: [] }); - - for await (const entry of entries) { - const isSecondary = entry.key[2] === 'secondaries'; - const statsKey = entry.key.slice(1, isSecondary ? 5 : 2).join('/'); - - if (isSecondary) { - stats[statsKey] = entry.value.length; - continue; - } - - if (!stats[statsKey]) { - stats[statsKey] = 0; - } - - stats[statsKey]++; - } - - return ctx.json({ stats }); - }); - - async function kvFilterHandler(match?: string) { - const data: unknown[] = []; - const entries = kv.list({ prefix: [] }); - - for await (const entry of entries) { - const key = Array.from(entry.key); - const keyPath = key.join('/'); - - // if match is provided, only show entries that match the path - if (match && keyPath.indexOf(match) === -1) { - continue; - } - - data.push({ key: keyPath, value: entry.value }); - } - - return data; - } - - baseRouter.get(`/raw/:match?`, async (ctx: Context) => { - return ctx.json(await kvFilterHandler(ctx.req.param('match'))); - }); - - baseRouter.get(`/logs`, async (ctx: Context) => { - const data = await kvFilterHandler('stores/logging/log_'); - return ctx.json(data.reverse()); - }); - - baseRouter.get(`/log/:messageId`, async (ctx: Context) => { - const messageId = ctx.req.param('messageId'); - const values = await kv.get(Store.buildLogSecondaryKey(messageId)); - - if (!values.value) { - return ctx.json([]); - } - - const data: unknown[] = []; - - for (const logId of values.value) { - const value = await kv.get(Store.buildLogKey(logId)); - data.push(value.value); - } - - return ctx.json(data.reverse()); - }); - - baseRouter.delete(`/reset/:match?`, async (ctx: Context) => { - const match = ctx.req.param('match'); - const entries = kv.list({ prefix: [] }); - - for await (const entry of entries) { - const keyPath = Array.from(entry.key).join('/'); - - // if match is provided, only delete entries that match the path - if (match && keyPath.indexOf(`stores/${match}`) === -1) { - continue; - } - - await kv.delete(entry.key); - } - - return ctx.json({ message: 'fresh as new!', match }); - }); -}; diff --git a/src/routes/kv-admin-routes.ts b/src/routes/kv-admin-routes.ts new file mode 100644 index 0000000..89d6cad --- /dev/null +++ b/src/routes/kv-admin-routes.ts @@ -0,0 +1,151 @@ +import { Context } from 'hono'; +import { MessagesStoreInterface } from '../interfaces/messages-store-interface.ts'; +import { LogsStoreInterface } from '../interfaces/logs-store-interface.ts'; +import { AbstractKvStore } from '../stores/kv/abstract-kv-store.ts'; +import { Routes } from '../utils/routes.ts'; + +/** + * Handles admin routing for KV storage backend. + */ +export class KvAdminRoutes { + private basePath = `/admin`; + private routes = Routes.initHono({ basePath: this.basePath }); + + constructor( + private readonly messageStore: MessagesStoreInterface, + private readonly logsStore: LogsStoreInterface, + private readonly kv: Deno.Kv, + ) {} + + /** + * Gets the versioned base path for admin routes. + * @param {string} version - API version string. + * @returns {string} The complete base path including version. + */ + getBasePath(version: string) { + return `/${version}/${this.basePath.replace('/', '')}`; + } + + getRoutes() { + this.routes.get('/stats', async (c: Context) => { + const stats: Record = {}; + const entries = this.kv.list({ prefix: [] }); + + for await (const entry of entries) { + const isSecondary = entry.key[2] === 'secondaries'; + const statsKey = entry.key.slice(1, isSecondary ? 5 : 2).join('/'); + + if (isSecondary) { + stats[statsKey] = Array.isArray(entry.value) ? entry.value.length : 0; + continue; + } + + if (!stats[statsKey]) { + stats[statsKey] = 0; + } + + stats[statsKey]++; + } + + return c.json({ stats }); + }); + + const storageFilterHandler = async (match?: string) => { + const data: unknown[] = []; + const entries = this.kv.list({ prefix: [] }); + + for await (const entry of entries) { + const key = Array.from(entry.key); + const keyPath = key.join('/'); + + // if match is provided, only show entries that match the path + if (match && keyPath.indexOf(match) === -1) { + continue; + } + + data.push({ key: keyPath, value: entry.value }); + } + + return data; + }; + + this.routes.get('/raw/:match?', async (c: Context) => { + return c.json(await storageFilterHandler(c.req.param('match'))); + }); + + this.routes.get('/logs', async (c: Context) => { + const data = await storageFilterHandler('stores/logging/log_'); + return c.json(data.reverse()); + }); + + this.routes.get('/log/:messageId', async (c: Context) => { + const messageId = c.req.param('messageId'); + + try { + // Get log IDs for this message from secondary index + const secondaryKey = AbstractKvStore.buildLogSecondaryKey(messageId); + const logIdsResult = await this.kv.get(secondaryKey); + + if (!logIdsResult.value || logIdsResult.value.length === 0) { + return c.json({ + message: `No logs found for message ${messageId}`, + messageId, + logs: [], + }); + } + + // Fetch all log entries for this message + const logs: unknown[] = []; + for (const logId of logIdsResult.value) { + const logKey = AbstractKvStore.buildLogKey(logId); + const logEntry = await this.kv.get(logKey); + if (logEntry.value) { + logs.push(logEntry.value); + } + } + + // Sort logs by creation time (most recent first) + const sortedLogs = logs.sort((a: unknown, b: unknown) => { + const aLog = a as { created_at: string }; + const bLog = b as { created_at: string }; + const dateA = new Date(aLog.created_at).getTime(); + const dateB = new Date(bLog.created_at).getTime(); + return dateB - dateA; + }); + + return c.json({ + message: `Found ${logs.length} log entries for message ${messageId}`, + messageId, + logs: sortedLogs, + }); + } catch (error) { + console.error('Error retrieving logs for message:', messageId, error); + return c.json({ + error: 'Failed to retrieve logs', + messageId, + logs: [], + }, 500); + } + }); + + this.routes.delete('/reset/:match?', async (c: Context) => { + const match = c.req.param('match'); + const entries = this.kv.list({ prefix: [] }); + + for await (const entry of entries) { + const keyPath = Array.from(entry.key).join('/'); + + // if match is provided, only delete entries that match the path + if (match && keyPath.indexOf(`stores/${match}`) === -1) { + continue; + } + + await this.kv.delete(entry.key); + } + + return c.json({ message: 'fresh as new!', match }); + }); + + return this.routes; + } +} diff --git a/src/routes/message-routes.ts b/src/routes/message-routes.ts index 086e794..9ac233a 100644 --- a/src/routes/message-routes.ts +++ b/src/routes/message-routes.ts @@ -1,73 +1,98 @@ -import { Context, Hono } from 'hono'; import { z } from 'zod'; -import { VERSION } from '../main.ts'; -import { MESSAGE_STATUS, MessagePayload, MessageReceivedData } from '../stores/message-model.ts'; -import { MESSAGES_STORE_NAME, MessagesStore } from '../stores/messages-store.ts'; +import { MessagesStoreInterface } from '../interfaces/messages-store-interface.ts'; +import { MessageReceivedDataSchema, MessageReceivedResponseSchema, MessageStatusSchema } from '../schemas/message-schema.ts'; +import { SYSTEM_MESSAGE_TYPE, SystemMessage } from '../services/storage/kv-store.ts'; +import { MESSAGES_STORE_NAME } from '../stores/kv/kv-messages-store.ts'; import { Http } from '../utils/http.ts'; +import { Routes } from '../utils/routes.ts'; import { Security } from '../utils/security.ts'; -import { SYSTEM_MESSAGE_TYPE, SystemMessage } from '../utils/store.ts'; - -export const messageRoutes = (router: Hono, kv: Deno.Kv) => { - const store = new MessagesStore(kv); - const baseRouter = router.basePath(`/${VERSION}/messages`); - - baseRouter.get('/:id', async (ctx: Context) => { - const id = ctx.req.param('id'); - const result = await store.fetch(id); - - if (result.isErr()) { - return ctx.json({ error: result.error }, 404); - } - - return ctx.json(result.value); - }); - - baseRouter.get('/by-status/:status', async (ctx: Context) => { - const status = ctx.req.param('status'); - const statusZod = z.object({ status: z.nativeEnum(MESSAGE_STATUS) }); - const validate = statusZod.safeParse({ status: status.toUpperCase() }); - - if (!validate.success) { - return ctx.json({ error: `Unknown status ${status}` }, 400); - } - - const result = await store.fetchByStatus(validate.data.status); - - if (result.isErr()) { - return ctx.json({ error: result.error }, 404); - } - - return ctx.json(result.value); - }); - - baseRouter.post('/:url{.*?}', async (ctx: Context) => { - const nextId = store.buildModelIdWithPrefix(); - const callbackUrl = ctx.req.param('url'); - const publishAtDate = Http.delayExtract(ctx); - const headers = Http.extractHeaders(ctx); - - const message = { - id: Security.generateSortableId(), - type: SYSTEM_MESSAGE_TYPE.MESSAGE_RECEIVED, - object: MESSAGES_STORE_NAME, - data: { - id: nextId, - publishAt: publishAtDate, - payload: { - headers, - url: callbackUrl, - data: Http.isJson(ctx) ? await ctx.req.json() : undefined, - } as MessagePayload, - } as MessageReceivedData, - createdAt: new Date(), - } as SystemMessage; - - console.log(`[${new Date().toISOString()}] enqueue new message`, message.data); - - const result = await kv.enqueue(message); - - console.log(`[${new Date().toISOString()}] result enqueued`, result); - - return ctx.json({ id: nextId, publishAt: publishAtDate.toISOString() }, 201); - }); -}; + +/** + * Handles routing for message-related endpoints. + */ +export class MessageRoutes { + private basePath = `/messages`; + private routes = Routes.initHono({ basePath: this.basePath }); + + /** + * Creates a new MessageRoutes instance. + * @param {Deno.Kv} kv - The key-value store instance. + * @param {MessagesStoreInterface} messageStore - The message store implementation. + */ + constructor( + private readonly kv: Deno.Kv, + private readonly messageStore: MessagesStoreInterface, + ) {} + + /** + * Gets the versioned base path for message routes. + * @param {string} version - API version string. + * @returns {string} The complete base path including version. + */ + getBasePath(version: string) { + return `/${version}/${this.basePath.replace('/', '')}`; + } + + getRoutes() { + this.routes.get('/:id', async (c) => { + const id = c.req.param('id'); + const result = await this.messageStore.fetchOne(id); + + if (result.isErr()) { + return c.json({ error: result.error }, 404); + } + + return c.json(result.value); + }); + + this.routes.get('/by-status/:status', async (c) => { + const status = c.req.param('status'); + const validate = MessageStatusSchema.safeParse(status.toUpperCase()); + + if (!validate.success) { + return c.json({ error: `Unknown status ${status}` }, 400); + } + + const result = await this.messageStore.fetchByStatus(validate.data); + + if (result.isErr()) { + return c.json({ error: result.error }, 404); + } + + return c.json(result.value); + }); + + this.routes.post('/:url{.*?}', async (c) => { + const nextId = this.messageStore.buildModelIdWithPrefix(); + const callbackUrl = c.req.param('url'); + const publishAtDate = Http.delayExtract(c); + const headers = Http.extractHeaders(c); + + const message = { + id: Security.generateSortableId(), + type: SYSTEM_MESSAGE_TYPE.MESSAGE_RECEIVED, + object: MESSAGES_STORE_NAME, + data: { + id: nextId, + publish_at: publishAtDate, + payload: { + headers, + url: callbackUrl, + data: Http.isJson(c) ? await c.req.json() : undefined, + }, + } as z.infer, + created_at: new Date(), + } as SystemMessage; + + console.log(`[${new Date().toISOString()}] enqueue new message`, message.data); + + await this.kv.enqueue(message); + + console.log(`[${new Date().toISOString()}] message enqueued with id ${nextId}`); + + return c.json({ id: nextId, publish_at: publishAtDate.toISOString() } as z.infer, 201); + }); + + return this.routes; + } +} diff --git a/src/routes/system-routes.ts b/src/routes/system-routes.ts new file mode 100644 index 0000000..cab17b2 --- /dev/null +++ b/src/routes/system-routes.ts @@ -0,0 +1,34 @@ +import { Context } from 'hono'; +import { Routes } from '../utils/routes.ts'; + +/** + * Handles routing for system-related endpoints. + */ +export class SystemRoutes { + private basePath = `/system`; + private routes = Routes.initHono({ basePath: this.basePath }); + + /** + * Gets the versioned base path for system routes. + * @param {string} version - API version string. + * @returns {string} The complete base path including version. + */ + getBasePath(version: string) { + return `/${version}/${this.basePath.replace('/', '')}`; + } + + getRoutes() { + this.routes.get('/ping', (c: Context) => { + return c.text('pong'); + }); + + this.routes.get('/health', (c: Context) => { + return c.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + }); + }); + + return this.routes; + } +} diff --git a/src/routes/turso-admin-routes.ts b/src/routes/turso-admin-routes.ts new file mode 100644 index 0000000..43e27ac --- /dev/null +++ b/src/routes/turso-admin-routes.ts @@ -0,0 +1,123 @@ +import { Context } from 'hono'; +import { Client } from 'libsql-core'; +import { MessagesStoreInterface } from '../interfaces/messages-store-interface.ts'; +import { LogsStoreInterface } from '../interfaces/logs-store-interface.ts'; +import { TursoLogsStore } from '../stores/turso/turso-logs-store.ts'; +import { Routes } from '../utils/routes.ts'; + +/** + * Handles admin routing for Turso/SQLite storage backend. + */ +export class TursoAdminRoutes { + private basePath = `/admin`; + private routes = Routes.initHono({ basePath: this.basePath }); + + constructor( + private readonly messageStore: MessagesStoreInterface, + private readonly logsStore: LogsStoreInterface, + private readonly sqlite: Client, + ) {} + + /** + * Gets the versioned base path for admin routes. + * @param {string} version - API version string. + * @returns {string} The complete base path including version. + */ + getBasePath(version: string) { + return `/${version}/${this.basePath.replace('/', '')}`; + } + + getRoutes() { + this.routes.get('/stats', async (c: Context) => { + try { + // Get message counts by status + const stats: Record = {}; + + const statusResult = await this.sqlite.execute(` + SELECT status, COUNT(*) as count + FROM messages + GROUP BY status + `); + + for (const row of statusResult.rows) { + stats[`messages/${row.status as string}`] = row.count as number; + } + + // Get total count + const totalResult = await this.sqlite.execute('SELECT COUNT(*) as total FROM messages'); + stats['messages/total'] = totalResult.rows[0]?.total as number || 0; + + return c.json({ stats }); + } catch (error) { + console.error('Error getting stats:', error); + return c.json({ error: 'Failed to retrieve stats' }, 500); + } + }); + + this.routes.get('/raw/:match?', async (c: Context) => { + const match = c.req.param('match'); + + try { + if (match === 'messages' || !match) { + const result = await this.sqlite.execute('SELECT * FROM messages ORDER BY created_at DESC LIMIT 100'); + return c.json(result.rows.map((row) => ({ table: 'messages', data: row }))); + } else if (match === 'migrations') { + const result = await this.sqlite.execute('SELECT * FROM migrations ORDER BY applied_at DESC'); + return c.json(result.rows.map((row) => ({ table: 'migrations', data: row }))); + } else { + return c.json({ message: `Unknown table: ${match}` }, 400); + } + } catch (error) { + console.error('Error getting raw data:', error); + return c.json({ error: 'Failed to retrieve raw data' }, 500); + } + }); + + this.routes.get('/logs', async (c: Context) => { + try { + const logs = await (this.logsStore as TursoLogsStore).fetchAll(100); + return c.json(logs); + } catch (error) { + console.error('Error fetching logs:', error); + return c.json({ error: 'Failed to retrieve logs' }, 500); + } + }); + + this.routes.get('/log/:messageId', async (c: Context) => { + const messageId = c.req.param('messageId'); + try { + const logs = await (this.logsStore as TursoLogsStore).fetchByMessageId(messageId); + return c.json({ messageId, logs }); + } catch (error) { + console.error('Error fetching logs for message:', error); + return c.json({ error: 'Failed to retrieve logs for message' }, 500); + } + }); + + this.routes.delete('/reset/:match?', async (c: Context) => { + const match = c.req.param('match'); + + try { + if (match === 'messages' || !match) { + await this.sqlite.execute('DELETE FROM messages'); + await (this.logsStore as TursoLogsStore).reset(); + return c.json({ message: 'Messages and logs tables reset!', match: match || 'all' }); + } else if (match === 'logs') { + await (this.logsStore as TursoLogsStore).reset(); + return c.json({ message: 'Logs table reset!', match }); + } else if (match === 'migrations') { + return c.json({ + message: 'Cannot reset migrations table - this would break the database structure', + }, 400); + } else { + return c.json({ message: `Unknown table: ${match}` }, 400); + } + } catch (error) { + console.error('Error resetting data:', error); + return c.json({ error: 'Failed to reset data' }, 500); + } + }); + + return this.routes; + } +} diff --git a/src/schemas/log-schema.ts b/src/schemas/log-schema.ts new file mode 100644 index 0000000..424cef4 --- /dev/null +++ b/src/schemas/log-schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const LogMessageDataSchema = z.object({ + id: z.string().regex(/^log_/).optional(), + type: z.string(), + object: z.string(), + message_id: z.string(), + before_data: z.record(z.any()), + after_data: z.record(z.any()), + created_at: z.date(), +}); + +export const LogMessageModelSchema = LogMessageDataSchema.extend({ + id: z.string().regex(/^log_/), +}); diff --git a/src/schemas/message-schema.ts b/src/schemas/message-schema.ts new file mode 100644 index 0000000..48416b1 --- /dev/null +++ b/src/schemas/message-schema.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +export const MessageHeadersSchema = z.object({ + command: z.record(z.string(), z.string()), + forward: z.record(z.string(), z.string()), +}); + +export const MessagePayloadSchema = z.object({ + headers: MessageHeadersSchema, + url: z.string(), + data: z.object({}).optional(), +}); + +export const MessageReceivedDataSchema = z.object({ + id: z.string().regex(/^msg_/), + publish_at: z.date(), + payload: MessagePayloadSchema, +}); + +export const MessageStatusSchema = z.enum(['CREATED', 'QUEUED', 'DELIVER', 'SENT', 'RETRY', 'DLQ', 'ARCHIVED']); + +export const MessageSchema = z.object({ + id: z.string().regex(/^msg_/), + payload: MessagePayloadSchema, + status: MessageStatusSchema, + delivered_at: z.date().optional(), + publish_at: z.date(), + created_at: z.date(), + updated_at: z.date(), +}); + +export const MessageReceivedResponseSchema = z.object({ + id: z.string(), + publish_at: z.string().datetime(), +}); + +export const MessageResponseSchema = MessageSchema.extend({ + delivered_at: z.string().datetime().optional(), + publish_at: z.string().datetime(), + created_at: z.string().datetime(), + updated_at: z.string().datetime(), +}); diff --git a/src/schemas/system-schema.ts b/src/schemas/system-schema.ts new file mode 100644 index 0000000..24bc0ce --- /dev/null +++ b/src/schemas/system-schema.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +export const HasDatesSchema = z.object({ + created_at: z.date(), + updated_at: z.date(), +}); + +export const ModelSchema = HasDatesSchema.extend({ + id: z.string(), +}); + +export const SystemMessageTypeSchema = z.union([ + z.literal('STORE_CREATE_EVENT'), + z.literal('STORE_UPDATE_EVENT'), + z.literal('STORE_DELETE_EVENT'), + z.literal('MESSAGE_RECEIVED'), + z.literal('MESSAGE_QUEUED'), + z.literal('MESSAGE_RETRY'), +]); + +export const SystemMessageStatusSchema = z.union([ + z.literal('CREATED'), + z.literal('RECEIVED'), + z.literal('PROCESSED'), + z.literal('IGNORE'), +]); + +export const SystemMessageSchema = z.object({ + id: z.string(), + type: SystemMessageTypeSchema, + data: z.unknown(), + object: z.string(), + created_at: z.date(), +}); + +export const SecondaryTypeSchema = z.union([ + z.literal('ONE'), + z.literal('MANY'), +]); + +export const SecondarySchema = z.object({ + type: SecondaryTypeSchema, + key: z.array(z.string()), + value: z.string().or(z.array(z.string())).optional(), +}); diff --git a/src/services/auth-middleware.ts b/src/services/auth-middleware.ts new file mode 100644 index 0000000..0f57e35 --- /dev/null +++ b/src/services/auth-middleware.ts @@ -0,0 +1,27 @@ +import type { Context, Next } from 'hono'; + +export interface AuthConfig { + token: string; + skipPaths?: string[]; +} + +export class AuthMiddleware { + static bearer(config: AuthConfig) { + const skipPaths = config.skipPaths || []; + + return async (c: Context, next: Next) => { + // Check if path should skip auth + if (skipPaths.some((path) => c.req.path === path)) { + await next(); + return; + } + + const auth = c.req.header('Authorization'); + if (!auth || !auth.startsWith('Bearer ') || auth.split(' ')[1] !== config.token) { + return c.json({ error: 'Unauthorized' }, 401); + } + + await next(); + }; + } +} diff --git a/src/services/storage/kv-store.ts b/src/services/storage/kv-store.ts new file mode 100644 index 0000000..599c23a --- /dev/null +++ b/src/services/storage/kv-store.ts @@ -0,0 +1,356 @@ +import { diff } from 'deep-object-diff'; +import { Security } from '../../utils/security.ts'; + +export enum SYSTEM_MESSAGE_TYPE { + STORE_CREATE_EVENT = 'STORE_CREATE_EVENT', + STORE_UPDATE_EVENT = 'STORE_UPDATE_EVENT', + STORE_DELETE_EVENT = 'STORE_DELETE_EVENT', + MESSAGE_RECEIVED = 'MESSAGE_RECEIVED', + MESSAGE_QUEUED = 'MESSAGE_QUEUED', + MESSAGE_RETRY = 'MESSAGE_RETRY', +} + +export type SystemMessage = { + id: string; + type: SYSTEM_MESSAGE_TYPE; + data: unknown; + object: string; + created_at: Date; +}; + +export enum SECONDARY_TYPE { + ONE = 'ONE', + MANY = 'MANY', +} + +export type Secondary = { + type: SECONDARY_TYPE; + key: string[]; + value?: string[]; +}; + +/** + * Abstract base class for key-value store operations. + * Provides common functionality for storing and retrieving data using Deno.Kv. + */ +export abstract class KvStore { + constructor(protected kv: Deno.Kv) {} + + /** + * Gets the name of the store. + * @returns {string} The store name. + */ + abstract getStoreName(): string; + + /** + * Gets the prefix used for model IDs in this store. + * @returns {string} The model ID prefix. + */ + abstract getModelIdPrefix(): string; + + /** + * Generates a unique log ID with a sortable timestamp. + * @returns {string} A unique log ID in the format "log_[sortableId]". + */ + static buildLogId() { + return `log_${Security.generateSortableId()}`; + } + + static buildLogKey(logId: string) { + return [...KvStore.getStoresBaseKey(), 'logging', logId]; + } + + static buildLogSecondaryKey(messageId: string) { + return [...KvStore.getStoresBaseKey(), 'logging', 'secondaries', 'BY_MESSAGE_ID', messageId]; + } + + static getStoresBaseKey() { + return ['stores']; + } + + static getCollectionBaseSecondaryKey() { + return [...KvStore.getStoresBaseKey(), 'secondary']; + } + + /** + * Generates a unique model ID. + * @returns {string} A unique model identifier. + */ + buildModelId() { + return Security.generateId(); + } + + buildModelIdWithPrefix() { + return `${this.getModelIdPrefix().toLowerCase()}_${this.buildModelId()}`; + } + + /** + * Gets secondary indices for a given model. + * Override this method to implement custom secondary indices. + * @param {unknown} model - The model to get secondaries for. + * @returns {Secondary[]} Array of secondary indices. + */ + // deno-lint-ignore no-unused-vars + getSecondaries(model: unknown): Secondary[] { + return []; + } + + /** + * Fetches multiple models by their IDs. + * @template Type The type of models to fetch. + * @param {string[]} ids - Array of model IDs to fetch. + * @returns {Promise} Array of fetched models, sorted by updated_at. + */ + async fetchMany(ids: string[]) { + const models: Type[] = []; + + for (const id of ids) { + const episode = await this._fetch(id); + + if (episode) { + models.push(episode); + } + } + + return this.sortByUpdatedAt(models as Array<{ updated_at: Date }>) as Type[]; + } + + sortByUpdatedAt(models: Array<{ updated_at: Date }>, direction: 'asc' | 'desc' = 'desc') { + models.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime()); + + if (direction === 'asc') { + models.reverse(); + } + + return models as Type[]; + } + + protected async _reset(options?: { prop: string; value: string }) { + const list = this._fetchAll>(); + + for await (const entry of list) { + let deleteEntry = true; + + if (options !== undefined) { + deleteEntry = Object.prototype.hasOwnProperty.call(entry.value, options.prop) && + entry.value[options.prop] === options.value; + } + + if (deleteEntry) { + await this.kv.delete(entry.key); + } + } + } + + protected async _fetch(id: string) { + // console.log(`- fetch from ${this.getStoreName()}`, { id }); + const entry = await this.kv.get(this.buildPrimaryKey(id), { consistency: 'eventual' }); + + return entry.value; + } + + protected _fetchAll(options?: Deno.KvListOptions) { + return this.kv.list({ prefix: this.buildPrimaryKey() }, options); + } + + /** + * Creates a new model in the store. + * @template Type The type of model to create. + * @param {object} data - The data to create the model with. + * @param {object} [options] - Optional creation options. + * @param {string} [options.withId] - Specific ID to use for the new model. + * @returns {Promise} The created model. + */ + protected async _create(data: object, options?: { withId: string }) { + const id = options?.withId || this.buildModelIdWithPrefix(); + const model = { id, ...data, created_at: new Date(), updated_at: new Date() }; + await this.kv.set(this.buildPrimaryKey(model.id), model); + + // HANDLE SECONDARIES + for (const secondary of this.getSecondaries(model)) { + secondary.value = secondary.value || [model.id]; + + if (secondary.type === SECONDARY_TYPE.MANY) { + const beforeRefs = await this._fetchSecondary(secondary.key); + if (beforeRefs) secondary.value = [...beforeRefs, ...secondary.value]; + } + + await this._addSecondary(secondary); + } + + await this.triggerWriteEvent(SYSTEM_MESSAGE_TYPE.STORE_CREATE_EVENT, { after: model }); + + return model as Type; + } + + /** + * Updates an existing model in the store. + * @template Type The type of model to update. + * @param {string} id - The ID of the model to update. + * @param {Partial} data - The data to update the model with. + * @returns {Promise} The updated model. + * @throws {Error} If the model is not found. + */ + protected async _update(id: string, data: Partial) { + const before = await this._fetch(id); + + if (!before) { + throw new Error(`model not found ${id}`); + } + + const after = { ...before, ...data, updated_at: new Date() }; + await this.kv.set(this.buildPrimaryKey(id), after); + + // HANDLE SECONDARIES + const secondariesWithOldData = this.getSecondaries(before); + + for (const [index, secondary] of Object.entries(this.getSecondaries(after))) { + const oldKey = secondariesWithOldData[Number(index)].key; + const newKey = secondary.key; + + await this._updateSecondary(secondary.type, oldKey, newKey, secondary.value || [id]); + } + + await this.triggerWriteEvent(SYSTEM_MESSAGE_TYPE.STORE_UPDATE_EVENT, { before, after }); + + return after; + } + + protected async _delete(id: string) { + const before = await this._fetch(id); + await this.kv.delete(this.buildPrimaryKey(id)); + + // HANDLE SECONDARIES + + for (const secondary of this.getSecondaries(before)) { + await this._deleteSecondary(secondary.key); + } + + await this.triggerWriteEvent(SYSTEM_MESSAGE_TYPE.STORE_DELETE_EVENT, { before }); + } + + protected async _fetchSecondary(key: string[]) { + const secondaryKey = this.buildSecondaryKey(key); + const entry = await this.kv.get(secondaryKey); + + return entry.value; + } + + /** + * Casts data to the specified type, preserving only the data fields. + * @template Type The type to cast to. + * @param {Omit} data - The data to cast. + * @returns {Omit} The cast data. + */ + protected cast(data: Omit): Omit { + return data; + } + + protected buildPrimaryKey(id?: string) { + const keys = [...KvStore.getStoresBaseKey(), this.getStoreName()]; + + if (id) { + keys.push(id); + } + + return keys; + } + + private async _addSecondary(secondary: Secondary) { + // console.log('- adding secondary', { key: secondary.key, values: secondary.value }); + await this.kv.set(this.buildSecondaryKey(secondary.key), secondary.value); + } + + private async _updateSecondary(type: SECONDARY_TYPE, oldKey: string[], newKey: string[], value: string[]) { + const beforeValues = await this._fetchSecondary(oldKey); + + // // console.log('- evaluating secondary update', { oldKey, newKey, value, beforeValues }); + + const keyDidNotChange = oldKey.join('/') === newKey.join('/'); + + if (type === SECONDARY_TYPE.ONE) { + if (!keyDidNotChange) await this._deleteSecondary(oldKey); + await this._updatingSecondary(newKey, value); + return; + } + + // key did not change so we simply add the new value + if (keyDidNotChange) { + const newValues = beforeValues ? [...new Set([...beforeValues, ...value])] : value; + await this._updatingSecondary(oldKey, newValues); + return; + } + + // keys are different so we need to ... + + // 1. remove the value from old secondary + if (beforeValues) { + const newValue = value[0]; + const newBeforeValues = beforeValues.filter((before: string) => before !== newValue); + + switch (newBeforeValues.length) { + case 0: // remove complete secondary index since we dont have any refs + await this._deleteSecondary(oldKey); + break; + default: // update the secondary with the updated refs + await this._updatingSecondary(oldKey, newBeforeValues); + } + } + + // 2. add the value to the new secondary + const afterValues = await this._fetchSecondary(newKey); + const newAfterValues = afterValues ? [...new Set([...afterValues, ...value])] : value; + await this._updatingSecondary(newKey, newAfterValues); + } + + private async _updatingSecondary(key: string[], value: string[]) { + const unqiueValue = [...new Set(value)]; // remove duplicates + // console.log('- updating secondary', { key, value: unqiueValue }); + await this.kv.set(this.buildSecondaryKey(key), unqiueValue); + } + + private async _deleteSecondary(key: string[]) { + // console.log('- delete secondary', { key }); + await this.kv.delete(this.buildSecondaryKey(key)); + } + + private buildSecondaryKey(key: string[]) { + return [...this.buildPrimaryKey(), 'secondaries', ...key]; + } + + private async triggerWriteEvent(type: SYSTEM_MESSAGE_TYPE, data: { before?: unknown; after?: unknown }) { + const log: SystemMessage = { type, data, id: KvStore.buildLogId(), object: this.getStoreName(), created_at: new Date() }; + + // ############################################## + // enqueue message + await this.kv.enqueue(log); + + if (Deno.env.get('ENABLE_LOGS') !== 'true') { + return; + } + + // ############################################## + // handle logging if enabled + let messageId: string | undefined; + + switch (type) { + case SYSTEM_MESSAGE_TYPE.STORE_DELETE_EVENT: + messageId = (data as { before: { id: string } }).before.id; + break; + default: + messageId = (data as { after: { id: string } }).after.id; + } + + if (data.before && data.after) { + log.data = { id: messageId, diff: diff(data.before, data.after) }; + } + + // save log + await this.kv.set(KvStore.buildLogKey(log.id), log); + + // add secondary to lookup logs by message id + const secondaryKey = KvStore.buildLogSecondaryKey(messageId); + const values = await this.kv.get(secondaryKey); + await this.kv.set(secondaryKey, Array.isArray(values.value) ? [...values.value, log.id] : [log.id]); + } +} diff --git a/src/services/storage/sqlite-store.ts b/src/services/storage/sqlite-store.ts new file mode 100644 index 0000000..d3e5e61 --- /dev/null +++ b/src/services/storage/sqlite-store.ts @@ -0,0 +1,80 @@ +import type { Client } from 'libsql-core'; + +export type InMemory = ':memory:'; + +export type SqliteConfig = { + url: URL | InMemory; + authToken?: string; +}; + +export class SqliteStore { + private client!: Client; + + static async create(urlString: string, authToken?: string) { + let url: URL | InMemory; + url = ':memory:'; + + if (urlString !== ':memory:') { + url = new URL(urlString); + } + + const sqlite = new SqliteStore({ url, authToken }); + + return await sqlite.getClient(); + } + + constructor(private readonly config: SqliteConfig) { + } + + async getClient() { + if (!this.client) { + await this.createClient(); + } + + return this.client; + } + + private async createClient() { + // in memory + if (this.config.url === ':memory:') { + const libsqlNode = await import('libsql-node'); + this.client = libsqlNode.createClient({ url: ':memory:' }); + await this.setPragma(); + + return this; + } + + // local db file + if (this.isFileUrl(this.config.url)) { + const libsqlNode = await import('libsql-node'); + this.client = libsqlNode.createClient({ url: this.config.url.href }); + await this.setPragma(); + + return this; + } + + // remote db + // due to deno limitations we need to use libsql-web + const libsqlWeb = await import('libsql-web'); + + this.client = libsqlWeb.createClient({ + url: this.config.url.href, + authToken: this.config.authToken, + }); + + return this; + } + + private isFileUrl(url: URL): boolean { + return url.href.startsWith('file:'); + } + + private async setPragma() { + await this.client.execute('PRAGMA journal_mode = WAL;'); + await this.client.execute('PRAGMA busy_timeout = 5000;'); + await this.client.execute('PRAGMA synchronous = NORMAL;'); + await this.client.execute('PRAGMA cache_size = 2000;'); + await this.client.execute('PRAGMA temp_store = MEMORY;'); + await this.client.execute('PRAGMA foreign_keys = true;'); + } +} diff --git a/src/utils/store.ts b/src/stores/kv/abstract-kv-store.ts similarity index 71% rename from src/utils/store.ts rename to src/stores/kv/abstract-kv-store.ts index 5df7ed9..ceff0e2 100644 --- a/src/utils/store.ts +++ b/src/stores/kv/abstract-kv-store.ts @@ -1,51 +1,10 @@ import { diff } from 'deep-object-diff'; -import { Security } from './security.ts'; - -export type HasDates = { - createdAt: Date; - updatedAt: Date; -}; - -export type Model = HasDates & { - id: string; -}; - -export enum SYSTEM_MESSAGE_TYPE { - STORE_CREATE_EVENT = 'STORE_CREATE_EVENT', - STORE_UPDATE_EVENT = 'STORE_UPDATE_EVENT', - STORE_DELETE_EVENT = 'STORE_DELETE_EVENT', - MESSAGE_RECEIVED = 'MESSAGE_RECEIVED', - MESSAGE_QUEUED = 'MESSAGE_QUEUED', - MESSAGE_RETRY = 'MESSAGE_RETRY', -} - -export enum SYSTEM_MESSAGE_STATUS { - CREATED = 'CREATED', - RECEIVED = 'RECEIVED', - PROCESSED = 'PROCESSED', - IGNORE = 'IGNORE', -} - -export type SystemMessage = { - id: string; - type: SYSTEM_MESSAGE_TYPE; - data: unknown; - object: string; - createdAt: Date; -}; - -export enum SECONDARY_TYPE { - ONE = 'ONE', - MANY = 'MANY', -} -export type Secondary = { - type: SECONDARY_TYPE; - key: string[]; - value?: string[]; -}; +import { z } from 'zod'; +import { HasDatesSchema, SecondarySchema, SecondaryTypeSchema, SystemMessageSchema, SystemMessageTypeSchema } from '../../schemas/system-schema.ts'; +import { Security } from '../../utils/security.ts'; -export abstract class Store { +export abstract class AbstractKvStore { constructor(protected kv: Deno.Kv) {} abstract getStoreName(): string; @@ -56,11 +15,11 @@ export abstract class Store { } static buildLogKey(logId: string) { - return [...Store.getStoresBaseKey(), 'logging', logId]; + return [...AbstractKvStore.getStoresBaseKey(), 'logging', logId]; } static buildLogSecondaryKey(messageId: string) { - return [...Store.getStoresBaseKey(), 'logging', 'secondaries', 'BY_MESSAGE_ID', messageId]; + return [...AbstractKvStore.getStoresBaseKey(), 'logging', 'secondaries', 'BY_MESSAGE_ID', messageId]; } static getStoresBaseKey() { @@ -68,7 +27,7 @@ export abstract class Store { } static getCollectionBaseSecondaryKey() { - return [...Store.getStoresBaseKey(), 'secondary']; + return [...AbstractKvStore.getStoresBaseKey(), 'secondary']; } buildModelId() { @@ -80,7 +39,7 @@ export abstract class Store { } // deno-lint-ignore no-unused-vars - getSecondaries(model: unknown): Secondary[] { + getSecondaries(model: unknown): z.infer[] { return []; } @@ -95,11 +54,11 @@ export abstract class Store { } } - return this.sortByUpdatedAt(models as HasDates[]) as Type[]; + return this.sortByUpdatedAt(models as z.infer[]) as Type[]; } - sortByUpdatedAt(models: HasDates[], direction: 'asc' | 'desc' = 'desc') { - models.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + sortByUpdatedAt(models: z.infer[], direction: 'asc' | 'desc' = 'desc') { + models.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime()); if (direction === 'asc') { models.reverse(); @@ -138,7 +97,7 @@ export abstract class Store { protected async _create(data: object, options?: { withId: string }) { const id = options?.withId || this.buildModelIdWithPrefix(); - const model = { id, ...data, createdAt: new Date(), updatedAt: new Date() }; + const model = { id, ...data, created_at: new Date(), updated_at: new Date() }; await this.kv.set(this.buildPrimaryKey(model.id), model); // HANDLE SECONDARIES @@ -146,7 +105,7 @@ export abstract class Store { for (const secondary of this.getSecondaries(model)) { secondary.value = secondary.value || [model.id]; - if (secondary.type === SECONDARY_TYPE.MANY) { + if (secondary.type === 'MANY') { const beforeRefs = await this._fetchSecondary(secondary.key); if (beforeRefs) secondary.value = [...beforeRefs, ...secondary.value]; } @@ -154,7 +113,7 @@ export abstract class Store { await this._addSecondary(secondary); } - await this.triggerWriteEvent(SYSTEM_MESSAGE_TYPE.STORE_CREATE_EVENT, { after: model }); + await this.triggerWriteEvent('STORE_CREATE_EVENT', { after: model }); return model as Type; } @@ -166,7 +125,7 @@ export abstract class Store { throw new Error(`model not found ${id}`); } - const after = { ...before, ...data, updatedAt: new Date() }; + const after = { ...before, ...data, updated_at: new Date() }; await this.kv.set(this.buildPrimaryKey(id), after); // HANDLE SECONDARIES @@ -177,10 +136,11 @@ export abstract class Store { const oldKey = secondariesWithOldData[Number(index)].key; const newKey = secondary.key; - await this._updateSecondary(secondary.type, oldKey, newKey, secondary.value || [id]); + const value = Array.isArray(secondary.value) ? secondary.value : [secondary.value || id]; + await this._updateSecondary(secondary.type, oldKey, newKey, value); } - await this.triggerWriteEvent(SYSTEM_MESSAGE_TYPE.STORE_UPDATE_EVENT, { before, after }); + await this.triggerWriteEvent('STORE_UPDATE_EVENT', { before, after }); return after; } @@ -195,7 +155,7 @@ export abstract class Store { await this._deleteSecondary(secondary.key); } - await this.triggerWriteEvent(SYSTEM_MESSAGE_TYPE.STORE_DELETE_EVENT, { before }); + await this.triggerWriteEvent('STORE_DELETE_EVENT', { before }); } protected async _fetchSecondary(key: string[]) { @@ -205,12 +165,12 @@ export abstract class Store { return entry.value; } - protected cast(data: Omit): Omit { + protected cast(data: Omit): Omit { return data; } protected buildPrimaryKey(id?: string) { - const keys = [...Store.getStoresBaseKey(), this.getStoreName()]; + const keys = [...AbstractKvStore.getStoresBaseKey(), this.getStoreName()]; if (id) { keys.push(id); @@ -219,19 +179,19 @@ export abstract class Store { return keys; } - private async _addSecondary(secondary: Secondary) { + private async _addSecondary(secondary: z.infer) { // console.log('- adding secondary', { key: secondary.key, values: secondary.value }); await this.kv.set(this.buildSecondaryKey(secondary.key), secondary.value); } - private async _updateSecondary(type: SECONDARY_TYPE, oldKey: string[], newKey: string[], value: string[]) { + private async _updateSecondary(type: z.infer, oldKey: string[], newKey: string[], value: string[]) { const beforeValues = await this._fetchSecondary(oldKey); // // console.log('- evaluating secondary update', { oldKey, newKey, value, beforeValues }); const keyDidNotChange = oldKey.join('/') === newKey.join('/'); - if (type === SECONDARY_TYPE.ONE) { + if (type === 'ONE') { if (!keyDidNotChange) await this._deleteSecondary(oldKey); await this._updatingSecondary(newKey, value); return; @@ -281,8 +241,8 @@ export abstract class Store { return [...this.buildPrimaryKey(), 'secondaries', ...key]; } - private async triggerWriteEvent(type: SYSTEM_MESSAGE_TYPE, data: { before?: unknown; after?: unknown }) { - const log: SystemMessage = { type, data, id: Store.buildLogId(), object: this.getStoreName(), createdAt: new Date() }; + private async triggerWriteEvent(type: z.infer, data: { before?: unknown; after?: unknown }) { + const log: z.infer = { type, data, id: AbstractKvStore.buildLogId(), object: this.getStoreName(), created_at: new Date() }; // ############################################## // enqueue message @@ -297,7 +257,7 @@ export abstract class Store { let messageId: string | undefined; switch (type) { - case SYSTEM_MESSAGE_TYPE.STORE_DELETE_EVENT: + case 'STORE_DELETE_EVENT': messageId = (data as { before: { id: string } }).before.id; break; default: @@ -309,10 +269,10 @@ export abstract class Store { } // save log - await this.kv.set(Store.buildLogKey(log.id), log); + await this.kv.set(AbstractKvStore.buildLogKey(log.id), log); // add secondary to lookup logs by message id - const secondaryKey = Store.buildLogSecondaryKey(messageId); + const secondaryKey = AbstractKvStore.buildLogSecondaryKey(messageId); const values = await this.kv.get(secondaryKey); await this.kv.set(secondaryKey, Array.isArray(values.value) ? [...values.value, log.id] : [log.id]); } diff --git a/src/stores/kv/kv-logs-store.ts b/src/stores/kv/kv-logs-store.ts new file mode 100644 index 0000000..48b1cd4 --- /dev/null +++ b/src/stores/kv/kv-logs-store.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; +import { LogsStoreInterface } from '../../interfaces/logs-store-interface.ts'; +import { LogMessageDataSchema, LogMessageModelSchema } from '../../schemas/log-schema.ts'; +import { Secondary, SECONDARY_TYPE } from '../../services/storage/kv-store.ts'; +import { Security } from '../../utils/security.ts'; +import { AbstractKvStore } from './abstract-kv-store.ts'; + +enum SECONDARIES { + BY_MESSAGE_ID = 'BY_MESSAGE_ID', +} + +export const LOGS_STORE_NAME = 'logs'; +export const LOGS_MODEL_ID_PREFIX = 'log'; + +export class KvLogsStore extends AbstractKvStore implements LogsStoreInterface { + getStoreName() { + return LOGS_STORE_NAME; + } + + getModelIdPrefix(): string { + return LOGS_MODEL_ID_PREFIX; + } + + override buildModelId(): string { + return Security.generateId(); + } + + override getSecondaries(model: z.infer): Secondary[] { + return [ + { type: SECONDARY_TYPE.MANY, key: [SECONDARIES.BY_MESSAGE_ID, model.message_id] }, + ]; + } + + async create(data: z.infer, options?: { withId: string }): Promise> { + const response = await this._create>(data, options); + return response; + } +} diff --git a/src/stores/message-model.ts b/src/stores/kv/kv-message-model.ts similarity index 59% rename from src/stores/message-model.ts rename to src/stores/kv/kv-message-model.ts index 7d82ec8..aef962f 100644 --- a/src/stores/message-model.ts +++ b/src/stores/kv/kv-message-model.ts @@ -1,12 +1,4 @@ -export enum MESSAGE_STATUS { - CREATED = 'CREATED', - QUEUED = 'QUEUED', - DELIVER = 'DELIVER', - SENT = 'SENT', - RETRY = 'RETRY', - DLQ = 'DLQ', - ARCHIVED = 'ARCHIVED', -} +export type MESSAGE_STATUS = 'CREATED' | 'QUEUED' | 'DELIVER' | 'SENT' | 'RETRY' | 'DLQ' | 'ARCHIVED'; export type MessagePayload = { headers: { @@ -19,7 +11,7 @@ export type MessagePayload = { export type MessageReceivedData = { id: string; - publishAt: Date; + publish_at: Date; payload: MessagePayload; }; @@ -27,21 +19,21 @@ export type MessageLastError = { url: string; status?: number; message: string; - createdAt: Date; + created_at: Date; }; export type MessageData = { payload: MessagePayload; - publishAt: Date; - deliveredAt?: Date; - retryAt?: Date; + publish_at: Date; + delivered_at?: Date; + retry_at?: Date; retried?: number; status: MESSAGE_STATUS; - lastErrors?: MessageLastError[]; + last_errors?: MessageLastError[]; }; export type MessageModel = MessageData & { id: string; - createdAt: Date; - updatedAt: Date; + created_at: Date; + updated_at: Date; }; diff --git a/src/stores/messages-store.ts b/src/stores/kv/kv-messages-store.ts similarity index 65% rename from src/stores/messages-store.ts rename to src/stores/kv/kv-messages-store.ts index 4d80d70..08c5171 100644 --- a/src/stores/messages-store.ts +++ b/src/stores/kv/kv-messages-store.ts @@ -1,8 +1,10 @@ -import { err, ok } from 'result'; -import { Dates } from '../utils/dates.ts'; -import { Security } from '../utils/security.ts'; -import { Secondary, SECONDARY_TYPE, Store } from '../utils/store.ts'; -import { MESSAGE_STATUS, MessageData, MessageModel, MessageReceivedData } from './message-model.ts'; +import { err, ok, Result } from 'result'; +import { MessagesStoreInterface } from '../../interfaces/messages-store-interface.ts'; +import { Secondary, SECONDARY_TYPE } from '../../services/storage/kv-store.ts'; +import { Dates } from '../../utils/dates.ts'; +import { Security } from '../../utils/security.ts'; +import { AbstractKvStore } from './abstract-kv-store.ts'; +import { MESSAGE_STATUS, MessageData, MessageModel, MessageReceivedData } from './kv-message-model.ts'; enum SECONDARIES { BY_STATUS = 'BY_STATUS', @@ -12,12 +14,12 @@ enum SECONDARIES { export const MESSAGES_STORE_NAME = 'messages'; export const MESSAGES_MODEL_ID_PREFIX = 'msg'; -export class MessagesStore extends Store { - override getStoreName() { +export class KvMessagesStore extends AbstractKvStore implements MessagesStoreInterface { + getStoreName() { return MESSAGES_STORE_NAME; } - override getModelIdPrefix(): string { + getModelIdPrefix(): string { return MESSAGES_MODEL_ID_PREFIX; } @@ -28,11 +30,11 @@ export class MessagesStore extends Store { override getSecondaries(model: MessageModel): Secondary[] { return [ { type: SECONDARY_TYPE.MANY, key: [SECONDARIES.BY_STATUS, model.status] }, - { type: SECONDARY_TYPE.MANY, key: [SECONDARIES.BY_PUBLISH_DATE, Dates.getDateOnly(model.publishAt)] }, + { type: SECONDARY_TYPE.MANY, key: [SECONDARIES.BY_PUBLISH_DATE, Dates.getDateOnly(model.publish_at)] }, ]; } - async fetch(id: string) { + async fetchOne(id: string): Promise> { const model = await this._fetch(id); if (model === null) { @@ -63,9 +65,7 @@ export class MessagesStore extends Store { } async createFromReceivedData(data: MessageReceivedData) { - const response = await this.create({ payload: data.payload, publishAt: data.publishAt, status: MESSAGE_STATUS.CREATED }, { withId: data.id }); - - return ok(response); + return await this.create({ payload: data.payload, publish_at: data.publish_at, status: 'CREATED' }, { withId: data.id }); } async create(data: MessageData, options?: { withId: string }) { @@ -80,7 +80,9 @@ export class MessagesStore extends Store { return ok(response); } - async delete(id: string) { - ok(await this._delete(id)); + async delete(id: string): Promise> { + await this._delete(id); + + return ok(true); } } diff --git a/src/stores/kv/kv-util-store.ts b/src/stores/kv/kv-util-store.ts new file mode 100644 index 0000000..253390d --- /dev/null +++ b/src/stores/kv/kv-util-store.ts @@ -0,0 +1,11 @@ +export class KvUtilStore { + constructor(private kv: Deno.Kv) {} + + async reset() { + console.log('resetting kv store'); + const entries = this.kv.list({ prefix: [] }); + for await (const entry of entries) { + await this.kv.delete(entry.key); + } + } +} diff --git a/src/stores/store-factory.ts b/src/stores/store-factory.ts new file mode 100644 index 0000000..4e5c7d6 --- /dev/null +++ b/src/stores/store-factory.ts @@ -0,0 +1,31 @@ +import { Client } from 'libsql-core'; +import { MessagesStoreInterface } from '../interfaces/messages-store-interface.ts'; +import { LogsStoreInterface } from '../interfaces/logs-store-interface.ts'; +import { KvMessagesStore } from './kv/kv-messages-store.ts'; +import { KvLogsStore } from './kv/kv-logs-store.ts'; +import { TursoMessagesStore } from './turso/turso-messages-store.ts'; +import { TursoLogsStore } from './turso/turso-logs-store.ts'; + +export type StorageType = 'KV' | 'TURSO'; + +export class StoreFactory { + static getStorageType(): StorageType { + return (Deno.env.get('STORAGE_TYPE') || 'KV') as StorageType; + } + + static getMessagesStore(instances: { kv: Deno.Kv; sqlite: Client }): MessagesStoreInterface { + if (this.getStorageType() === 'KV') { + return new KvMessagesStore(instances.kv); + } + + return new TursoMessagesStore(instances.sqlite); + } + + static getLogsStore(instances: { kv: Deno.Kv; sqlite: Client }): LogsStoreInterface { + if (this.getStorageType() === 'KV') { + return new KvLogsStore(instances.kv); + } + + return new TursoLogsStore(instances.sqlite); + } +} diff --git a/src/stores/turso/turso-logs-store.ts b/src/stores/turso/turso-logs-store.ts new file mode 100644 index 0000000..ebbccca --- /dev/null +++ b/src/stores/turso/turso-logs-store.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; +import { Client } from 'libsql-core'; +import { LogsStoreInterface } from '../../interfaces/logs-store-interface.ts'; +import { LogMessageDataSchema, LogMessageModelSchema } from '../../schemas/log-schema.ts'; +import { Security } from '../../utils/security.ts'; + +export const LOGS_STORE_NAME = 'logs'; +export const LOGS_MODEL_ID_PREFIX = 'log'; + +export class TursoLogsStore implements LogsStoreInterface { + constructor(private sqlite: Client) {} + + getStoreName(): string { + return LOGS_STORE_NAME; + } + + getModelIdPrefix(): string { + return LOGS_MODEL_ID_PREFIX; + } + + buildModelId(): string { + return Security.generateId(); + } + + buildModelIdWithPrefix(): string { + return `${this.getModelIdPrefix()}_${this.buildModelId()}`; + } + + async create( + data: z.infer, + options?: { withId: string }, + ): Promise> { + const id = options?.withId || this.buildModelIdWithPrefix(); + const now = Date.now(); + + await this.sqlite.execute({ + sql: `INSERT INTO logs (id, type, object, message_id, before_data, after_data, created_at) + VALUES (:id, :type, :object, :message_id, :before_data, :after_data, :created_at)`, + args: { + id, + type: data.type, + object: data.object, + message_id: data.message_id, + before_data: JSON.stringify(data.before_data), + after_data: JSON.stringify(data.after_data), + created_at: now, + }, + }); + + return { + id, + type: data.type, + object: data.object, + message_id: data.message_id, + before_data: data.before_data, + after_data: data.after_data, + created_at: new Date(now), + }; + } + + async fetchByMessageId(messageId: string): Promise[]> { + const result = await this.sqlite.execute({ + sql: `SELECT id, type, object, message_id, before_data, after_data, created_at + FROM logs + WHERE message_id = :message_id + ORDER BY created_at ASC`, + args: { message_id: messageId }, + }); + + return result.rows.map((row) => ({ + id: row.id as string, + type: row.type as string, + object: row.object as string, + message_id: row.message_id as string, + before_data: JSON.parse(row.before_data as string), + after_data: JSON.parse(row.after_data as string), + created_at: new Date(row.created_at as number), + })); + } + + async fetchAll(limit = 100): Promise[]> { + const result = await this.sqlite.execute({ + sql: `SELECT id, type, object, message_id, before_data, after_data, created_at + FROM logs + ORDER BY created_at DESC + LIMIT :limit`, + args: { limit }, + }); + + return result.rows.map((row) => ({ + id: row.id as string, + type: row.type as string, + object: row.object as string, + message_id: row.message_id as string, + before_data: JSON.parse(row.before_data as string), + after_data: JSON.parse(row.after_data as string), + created_at: new Date(row.created_at as number), + })); + } + + async reset(): Promise { + await this.sqlite.execute('DELETE FROM logs'); + } +} diff --git a/src/stores/turso/turso-messages-store.ts b/src/stores/turso/turso-messages-store.ts new file mode 100644 index 0000000..b49293e --- /dev/null +++ b/src/stores/turso/turso-messages-store.ts @@ -0,0 +1,198 @@ +import { Client, Row } from 'libsql-core'; +import { err, ok, Result } from 'result'; +import { MessagesStoreInterface } from '../../interfaces/messages-store-interface.ts'; +import { Dates } from '../../utils/dates.ts'; +import { Security } from '../../utils/security.ts'; +import { MESSAGE_STATUS, MessageData, MessageModel, MessageReceivedData } from '../kv/kv-message-model.ts'; + +export class TursoMessagesStore implements MessagesStoreInterface { + constructor(private sqlite: Client) {} + + getStoreName(): string { + return 'messages'; + } + + getModelIdPrefix(): string { + return 'msg'; + } + + buildModelId(): string { + return Security.generateId(); + } + + buildModelIdWithPrefix(): string { + return `${this.getModelIdPrefix().toLowerCase()}_${this.buildModelId()}`; + } + + async createFromReceivedData(data: MessageReceivedData) { + return await this.create({ payload: data.payload, publish_at: data.publish_at, status: 'CREATED' }, { withId: data.id }); + } + + async create( + data: MessageData, + options?: { withId?: string }, + ): Promise> { + try { + const now = new Date(); + const id = options?.withId || this.buildModelIdWithPrefix(); + const message: MessageModel = { + id, + ...data, + created_at: now, + updated_at: now, + }; + + const result = await this.sqlite.execute({ + sql: `INSERT INTO messages ( + id, payload, publish_at, delivered_at, retry_at, retried, status, last_errors, created_at, updated_at + ) VALUES (:id, :payload, :publish_at, :delivered_at, :retry_at, :retried, :status, :last_errors, :created_at, :updated_at)`, + args: { + id: message.id, + payload: JSON.stringify(message.payload), + publish_at: message.publish_at.toISOString(), + delivered_at: message.delivered_at?.toISOString() || null, + retry_at: message.retry_at?.toISOString() || null, + retried: message.retried || 0, + status: message.status, + last_errors: message.last_errors ? JSON.stringify(message.last_errors) : null, + created_at: message.created_at.toISOString(), + updated_at: message.updated_at.toISOString(), + }, + }); + + if (result.rowsAffected === 1) { + return ok(message); + } + + return err('Failed to create message'); + } catch (error: unknown) { + return err(`Database error: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async fetchOne(id: string): Promise> { + try { + const result = await this.sqlite.execute({ + sql: 'SELECT * FROM messages WHERE id = :id', + args: { id }, + }); + + if (result.rows.length === 0) { + return err('Unknown message'); + } + + return ok(this.rowToModel(result.rows[0])); + } catch (error: unknown) { + return err(`Database error: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async fetchByStatus(status: MESSAGE_STATUS): Promise> { + try { + const result = await this.sqlite.execute({ + sql: 'SELECT * FROM messages WHERE status = :status ORDER BY created_at DESC', + args: { status }, + }); + + return ok(result.rows.map((row) => this.rowToModel(row))); + } catch (error: unknown) { + return err(`Database error: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async fetchByDate(date: Date): Promise> { + try { + const dateOnly = Dates.getDateOnly(date); + const result = await this.sqlite.execute({ + sql: 'SELECT * FROM messages WHERE date(publish_at) = date(:date) ORDER BY publish_at ASC', + args: { date: dateOnly }, + }); + + return ok(result.rows.map((row) => this.rowToModel(row))); + } catch (error: unknown) { + return err(`Database error: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async update(id: string, data: Partial): Promise> { + try { + const setClauses: string[] = []; + const args: Record = { id }; + + // Build dynamic SET clause + if (data.payload) { + setClauses.push('payload = :payload'); + args.payload = JSON.stringify(data.payload); + } + if (data.publish_at) { + setClauses.push('publish_at = :publish_at'); + args.publish_at = data.publish_at.toISOString(); + } + if (data.delivered_at) { + setClauses.push('delivered_at = :delivered_at'); + args.delivered_at = data.delivered_at.toISOString(); + } + if (data.retry_at) { + setClauses.push('retry_at = :retry_at'); + args.retry_at = data.retry_at.toISOString(); + } + if (typeof data.retried === 'number') { + setClauses.push('retried = :retried'); + args.retried = data.retried; + } + if (data.status) { + setClauses.push('status = :status'); + args.status = data.status; + } + if (data.last_errors) { + setClauses.push('last_errors = :last_errors'); + args.last_errors = JSON.stringify(data.last_errors); + } + + // Add updated_at + setClauses.push('updated_at = :updated_at'); + args.updated_at = (data.updated_at || new Date()).toISOString(); + + const result = await this.sqlite.execute({ + sql: `UPDATE messages SET ${setClauses.join(', ')} WHERE id = :id`, + args, + }); + + if (result.rowsAffected === 0) { + return err('Message not found'); + } + + return this.fetchOne(id); + } catch (error: unknown) { + return err(`Database error: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async delete(id: string): Promise> { + try { + const result = await this.sqlite.execute({ + sql: 'DELETE FROM messages WHERE id = :id', + args: { id }, + }); + + return ok(result.rowsAffected > 0); + } catch (error: unknown) { + return err(`Database error: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private rowToModel(row: Row): MessageModel { + return { + id: row[0] as string, + payload: JSON.parse(row[1] as string), + publish_at: new Date(row[2] as string), + delivered_at: row[3] ? new Date(row[3] as string) : undefined, + retry_at: row[4] ? new Date(row[4] as string) : undefined, + retried: row[5] as number, + status: row[6] as MESSAGE_STATUS, + last_errors: row[7] ? JSON.parse(row[7] as string) : undefined, + created_at: new Date(row[8] as string), + updated_at: new Date(row[9] as string), + }; + } +} diff --git a/src/utils/dates.ts b/src/utils/dates.ts index 7c74a43..d3e7821 100644 --- a/src/utils/dates.ts +++ b/src/utils/dates.ts @@ -1,8 +1,21 @@ +/** + * Utility class for date formatting operations. + */ export class Dates { + /** + * Formats a date into YYYY-MM-DD format. + * @param {Date} date - The date to format. + * @returns {string} The formatted date string in YYYY-MM-DD format. + */ static getDateOnly(date: Date) { return [date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), String(date.getDate()).padStart(2, '0')].join('-'); } + /** + * Formats a date into YYYY-MM format. + * @param {Date} date - The date to format. + * @returns {string} The formatted date string in YYYY-MM format. + */ static getYearAndMonthDateOnly(date: Date) { return [date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0')].join('-'); } diff --git a/src/utils/env.ts b/src/utils/env.ts new file mode 100644 index 0000000..7d84b28 --- /dev/null +++ b/src/utils/env.ts @@ -0,0 +1,19 @@ +export class Env { + static get(key: string) { + return Deno.env.get(key) as string; + } + + static set(key: string, value: string) { + Deno.env.set(key, value); + return Env; + } + + static has(key: string) { + return Deno.env.has(key) === true; + } + + static delete(key: string) { + Deno.env.delete(key); + return Env; + } +} diff --git a/src/utils/http.ts b/src/utils/http.ts index c6c1802..4d51aac 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -3,7 +3,15 @@ import { err, ok } from 'result'; export const HTTP_NAMESPACE = 'Done'; +/** + * Utility class for HTTP-related operations. + */ export class Http { + /** + * Creates an AbortSignal that times out after the specified number of seconds. + * @param {number} timeoutInSeconds - The timeout duration in seconds (default: 8). + * @returns {AbortSignal} An AbortSignal that will timeout after the specified duration. + */ static getAbortSignal(timeoutInSeconds = 8) { return AbortSignal.timeout(timeoutInSeconds * 1000); } @@ -12,6 +20,13 @@ export class Http { return ctx.req.raw.headers.get('content-type') === 'application/json'; } + /** + * Validates DNS resolution for a given URL. + * @param {string} url - The URL to validate. + * @param {object} options - Validation options. + * @param {number} options.timeoutInSeconds - Timeout duration in seconds (default: 4). + * @returns {Promise>} Result indicating success or failure. + */ static async validateDns(url: string, options: { timeoutInSeconds: number } = { timeoutInSeconds: 4 }) { try { await Deno.resolveDns(new URL(url).hostname, 'A', { signal: Http.getAbortSignal(options.timeoutInSeconds) }); @@ -23,6 +38,11 @@ export class Http { } } + /** + * Extract the delay from the request headers. If the delay is not set, the current date is returned. + * @param {Context} ctx - The context of the request. + * @returns {Date} The delay date. + */ static delayExtract(ctx: Context) { const absolute = ctx.req.header(`${HTTP_NAMESPACE}-Not-Before`); const relative = ctx.req.header(`${HTTP_NAMESPACE}-Delay`); @@ -38,6 +58,11 @@ export class Http { return new Date(); } + /** + * Converts an absolute timestamp into a Date object. + * @param {string} notBefore - Unix timestamp in seconds. + * @returns {Date} The converted date. + */ static delayHandleAbsolute(notBefore: string) { return new Date(Number(notBefore) * 1000); } @@ -94,6 +119,15 @@ export class Http { return { command, forward }; } + /** + * Builds default callback headers with message tracking information. + * @param {HeadersInit} headers - Base headers to extend. + * @param {object} options - Options for the callback headers. + * @param {string} options.messageId - The message ID. + * @param {number} options.retried - Number of retries. + * @param {string} options.status - Current status. + * @returns {HeadersInit} Headers with added callback information. + */ static buildDefaultCallbackHeaders(headers: HeadersInit, options: { messageId: string; retried: number; status: string }) { return { ...headers, diff --git a/src/utils/migrations.ts b/src/utils/migrations.ts new file mode 100644 index 0000000..1a85d3c --- /dev/null +++ b/src/utils/migrations.ts @@ -0,0 +1,124 @@ +import { Client } from 'libsql-core'; +import { SqliteStore } from '../services/storage/sqlite-store.ts'; + +const MIGRATIONS_DIR = new URL('../../migrations', import.meta.url); +// const MIGRATIONS_DIR = 'migrations'; + +export class Migrations { + private client!: Client; + + constructor(private sqlite: SqliteStore) {} + + async migrate(options: { force: boolean } = { force: false }): Promise { + this.client = await this.sqlite.getClient(); + + // Get list of migration files + const migrationFiles = await this.getMigrationFiles(); + + // Get applied migrations + const appliedMigrations = await this.getAppliedMigrations(); + + // Apply migrations in order + for (const file of migrationFiles) { + if (!appliedMigrations.includes(file) || options.force) { + console.log(`Applying migration: ${file}`); + await this.applyMigration(file); + } + } + } + + private async getMigrationFiles(): Promise { + try { + const files = []; + for await (const entry of Deno.readDir(MIGRATIONS_DIR.pathname)) { + if (entry.isFile && entry.name.endsWith('.sql')) { + files.push(entry.name); + } + } + return files.sort(); // Sort to ensure order + } catch (error) { + console.error('Failed to read migration files:', error); + throw error; + } + } + + private async getAppliedMigrations(): Promise { + try { + const result = await this.client.execute('SELECT name FROM migrations ORDER BY id'); + return result.rows.map((row) => row[0] as string); + } catch (_error) { + // If table doesn't exist yet, return empty array + return []; + } + } + + private async applyMigration(filename: string): Promise { + try { + const sql = await Deno.readTextFile(`${MIGRATIONS_DIR.pathname}/${filename}`); + + // Start transaction + await this.client.execute('BEGIN TRANSACTION'); + + try { + // Split by semicolons to get individual SQL commands + const commands = sql.split(';'); + + for (const command of commands) { + // Process the command to remove comments and empty lines + const processedCommand = this.processCommand(command); + + // Only execute non-empty commands + if (processedCommand) { + await this.client.execute(`${processedCommand};`); + } + } + + // Record migration + await this.client.execute({ + sql: 'INSERT INTO migrations (id, name) VALUES (?, ?)', + args: [crypto.randomUUID(), filename], + }); + + // Commit transaction + await this.client.execute('COMMIT'); + } catch (error) { + // Rollback on error + await this.client.execute('ROLLBACK'); + throw error; + } + } catch (error) { + console.error(`Failed to apply migration ${filename}:`, error); + throw error; + } + } + + private processCommand(command: string): string { + // Split the command into lines + const lines = command.split('\n'); + const processedLines: string[] = []; + + // Process each line + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip empty lines and comment lines + if (trimmedLine === '' || trimmedLine.startsWith('--')) { + continue; + } + + // For lines with inline comments, only keep the part before the comment + const commentIndex = trimmedLine.indexOf('--'); + if (commentIndex >= 0) { + const lineBeforeComment = trimmedLine.substring(0, commentIndex).trim(); + if (lineBeforeComment) { + processedLines.push(lineBeforeComment); + } + } else { + processedLines.push(trimmedLine); + } + } + + // Join the processed lines and return + return processedLines.join(' ').trim(); + } +} diff --git a/src/utils/routes.ts b/src/utils/routes.ts new file mode 100644 index 0000000..b2cc39f --- /dev/null +++ b/src/utils/routes.ts @@ -0,0 +1,24 @@ +import { Hono } from 'hono'; + +/** + * Utility class for route initialization and management. + */ +export class Routes { + /** + * Initializes a new Hono router instance with optional base path. + * @param {object} [options] - Router initialization options. + * @param {string} [options.basePath] - Base path prefix for all routes. + * @returns {Hono} A new Hono router instance. + */ + static initHono(options?: { basePath?: string }) { + if (!options) options = {}; + + const routes = new Hono(); + + if (options?.basePath) { + routes.basePath(options.basePath); + } + + return routes; + } +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..3ea7490 --- /dev/null +++ b/src/version.ts @@ -0,0 +1 @@ +export const VERSION_STRING = 'v1'; diff --git a/tests/integration/routes/kv-admin-routes.test.ts b/tests/integration/routes/kv-admin-routes.test.ts new file mode 100644 index 0000000..333684e --- /dev/null +++ b/tests/integration/routes/kv-admin-routes.test.ts @@ -0,0 +1,271 @@ +import { assertEquals, assertExists } from 'jsr:@std/assert'; +import { afterEach, beforeEach, describe, it } from 'jsr:@std/testing/bdd'; +import { z } from 'zod'; +import { KvAdminRoutes } from '../../../src/routes/kv-admin-routes.ts'; +import { MessageSchema } from '../../../src/schemas/message-schema.ts'; +import { AuthMiddleware } from '../../../src/services/auth-middleware.ts'; +import { KvMessagesStore } from '../../../src/stores/kv/kv-messages-store.ts'; +import { KvLogsStore } from '../../../src/stores/kv/kv-logs-store.ts'; +import { KvUtilStore } from '../../../src/stores/kv/kv-util-store.ts'; +import { Routes } from '../../../src/utils/routes.ts'; +import { VERSION_STRING } from '../../../src/version.ts'; + +describe('KvAdminRoutes integration tests', () => { + let kv: Deno.Kv; + let messageStore: KvMessagesStore; + let adminRoutes: KvAdminRoutes; + let app: ReturnType; + + beforeEach(async () => { + kv = await Deno.openKv(); + messageStore = new KvMessagesStore(kv); + const logsStore = new KvLogsStore(kv); + adminRoutes = new KvAdminRoutes(messageStore, logsStore, kv); + + // Set up auth token for tests + Deno.env.set('AUTH_TOKEN', 'test-token'); + + // Set up app with routes and auth + app = Routes.initHono(); + app.use( + `/${VERSION_STRING}/*`, + AuthMiddleware.bearer({ + token: 'test-token', + skipPaths: [], + }), + ); + app.route(adminRoutes.getBasePath(VERSION_STRING), adminRoutes.getRoutes()); + }); + + afterEach(async () => { + await new KvUtilStore(kv).reset(); + kv.close(); + console.log('resetting kv store'); + }); + + describe('GET /admin/stats', () => { + it('should return empty stats when no data exists', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/stats`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertExists(body.stats); + assertEquals(typeof body.stats, 'object'); + }); + + it('should return stats with message data', async () => { + // Create some test messages + const message1: z.infer = { + id: 'msg_test1', + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED', + created_at: new Date(), + updated_at: new Date(), + }; + + const message2: z.infer = { + id: 'msg_test2', + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'QUEUED', + created_at: new Date(), + updated_at: new Date(), + }; + + await messageStore.create(message1); + await messageStore.create(message2); + + const req = new Request(`http://localhost/${VERSION_STRING}/admin/stats`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertExists(body.stats); + assertEquals(typeof body.stats, 'object'); + }); + }); + + describe('GET /admin/raw', () => { + it('should return raw KV data', async () => { + // Create a test message to ensure some data exists + const message: z.infer = { + id: 'msg_test1', + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED', + created_at: new Date(), + updated_at: new Date(), + }; + + await messageStore.create(message); + + const req = new Request(`http://localhost/${VERSION_STRING}/admin/raw`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertEquals(Array.isArray(body), true); + }); + + it('should filter raw data by match parameter', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/raw/stores`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertEquals(Array.isArray(body), true); + }); + }); + + describe('GET /admin/logs', () => { + it('should return log entries', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/logs`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertEquals(Array.isArray(body), true); + }); + }); + + describe('GET /admin/log/:messageId', () => { + it('should return empty logs for non-existent message', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/log/msg_nonexistent`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertExists(body.message); + assertEquals(body.message.includes('No logs found'), true); + assertEquals(body.logs.length, 0); + assertEquals(body.messageId, 'msg_nonexistent'); + }); + + it('should return logs for message with activity', async () => { + // First create and update a message to generate logs + const message: z.infer = { + id: 'msg_test_logs', + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED', + created_at: new Date(), + updated_at: new Date(), + }; + + await messageStore.create(message); + + // Update the message to generate more logs + await messageStore.update('msg_test_logs', { status: 'QUEUED' }); + + const req = new Request(`http://localhost/${VERSION_STRING}/admin/log/msg_test_logs`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertExists(body.logs); + assertEquals(Array.isArray(body.logs), true); + assertEquals(body.messageId, 'msg_test_logs'); + // Should have at least create and update logs if logging is enabled + if (Deno.env.get('ENABLE_LOGS') === 'true') { + assertEquals(body.logs.length >= 2, true); + } + }); + }); + + describe('DELETE /admin/reset', () => { + it('should reset all KV data', async () => { + // Create a test message + const message: z.infer = { + id: 'msg_test1', + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED', + created_at: new Date(), + updated_at: new Date(), + }; + + await messageStore.create(message); + + const req = new Request(`http://localhost/${VERSION_STRING}/admin/reset`, { + method: 'DELETE', + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertEquals(body.message, 'fresh as new!'); + }); + + it('should reset filtered KV data by match', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/reset/messages`, { + method: 'DELETE', + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertEquals(body.message, 'fresh as new!'); + assertEquals(body.match, 'messages'); + }); + }); +}); diff --git a/tests/integration/routes/kv-message-routes.test.ts b/tests/integration/routes/kv-message-routes.test.ts new file mode 100644 index 0000000..41a4136 --- /dev/null +++ b/tests/integration/routes/kv-message-routes.test.ts @@ -0,0 +1,182 @@ +import { assert, assertEquals, assertExists } from 'jsr:@std/assert'; +import { afterEach, beforeEach, describe, it } from 'jsr:@std/testing/bdd'; +import { z } from 'zod'; +import { MessageRoutes } from '../../../src/routes/message-routes.ts'; +import { MessageReceivedResponseSchema, MessageResponseSchema, MessageSchema } from '../../../src/schemas/message-schema.ts'; +import { KvMessagesStore } from '../../../src/stores/kv/kv-messages-store.ts'; +import { KvUtilStore } from '../../../src/stores/kv/kv-util-store.ts'; +import { Routes } from '../../../src/utils/routes.ts'; +import { VERSION_STRING } from '../../../src/version.ts'; + +describe('KvMessageRoutes integration tests', () => { + let kv: Deno.Kv; + let messageStore: KvMessagesStore; + let routes: MessageRoutes; + let app: ReturnType; + + beforeEach(async () => { + kv = await Deno.openKv(); + messageStore = new KvMessagesStore(kv); + + // Setup routes + routes = new MessageRoutes(kv, messageStore); + app = Routes.initHono(); + app.route(`/${VERSION_STRING}/messages`, routes.getRoutes()); + }); + + afterEach(async () => { + await new KvUtilStore(kv).reset(); + kv.close(); + }); + + describe('POST /:url', () => { + it('should create a new message', async () => { + const payload = { test: true }; + const req = new Request(`http://localhost/${VERSION_STRING}/messages/https://example.com/callback`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + const res = await app.fetch(req); + assertEquals(res.status, 201); + + const body = await res.json(); + const validate = MessageReceivedResponseSchema.safeParse(body); + assertEquals(validate.success, true, `Invalid response body: ${JSON.stringify(body)}`); + assertExists(validate.data); + assertExists(validate.data.id); + assertExists(validate.data.publish_at); + assertEquals(validate.data.id.startsWith('msg_'), true); + }); + + it('should create a new message with delayed publish date', async () => { + const payload = { test: true }; + const req = new Request(`http://localhost/${VERSION_STRING}/messages/https://example.com/callback`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Done-Delay': '5s', + }, + body: JSON.stringify(payload), + }); + + const res = await app.fetch(req); + assertEquals(res.status, 201); + + const body = await res.json(); + const validate = MessageReceivedResponseSchema.safeParse(body); + assertEquals(validate.success, true, `Invalid response body: ${JSON.stringify(body)}`); + assertExists(validate.data); + assertExists(validate.data.id); + assertExists(validate.data.publish_at); + assertEquals(validate.data.id.startsWith('msg_'), true); + + // Verify the publish date is in the future + const publishAt = new Date(validate.data.publish_at); + const now = new Date(); + assertEquals(publishAt > now, true); + }); + }); + + describe('GET /:id', () => { + it('should fetch a message by id', async () => { + // Create a test message + const message: z.infer = { + id: 'msg_test1', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED', + created_at: new Date(), + updated_at: new Date(), + }; + + await messageStore.create(message); + + // Make request + const req = new Request(`http://localhost/${VERSION_STRING}/messages/msg_test1`); + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + const validate = MessageResponseSchema.safeParse(body); + + assert(validate.success, `Invalid response body: ${JSON.stringify(body)}`); + assertExists(validate.data, `Missing message data`); + assertEquals(validate.data.id, message.id, `Invalid message id: ${validate.data.id}`); + assertEquals(validate.data.status, message.status, `Invalid message status: ${validate.data.status}`); + }); + + it('should return 404 for non-existent message', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/messages/non_existent`); + const res = await app.fetch(req); + assertEquals(res.status, 404); + }); + }); + + describe('GET /by-status/:status', () => { + it('should fetch messages by status', async () => { + // Create test messages + const message1: z.infer = { + id: 'msg_test1', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'QUEUED', + created_at: new Date(), + updated_at: new Date(), + }; + + const message2: z.infer = { + id: 'msg_test2', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'QUEUED', + created_at: new Date(), + updated_at: new Date(), + }; + + await messageStore.create(message1); + await messageStore.create(message2); + + // Make request + const req = new Request(`http://localhost/${VERSION_STRING}/messages/by-status/queued`); + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + const validate = z.array(MessageResponseSchema).safeParse(body); + + assertEquals(validate.success, true, `Invalid response body: ${JSON.stringify(body)}`); + assertExists(validate.data, `Missing message data`); + assertEquals(validate.data.length, 2, `Invalid number of messages: ${validate.data.length}`); + assertEquals(validate.data[0].status, 'QUEUED', `Invalid message status: ${validate.data[0].status}`); + assertEquals(validate.data[1].status, 'QUEUED', `Invalid message status: ${validate.data[1].status}`); + }); + + it('should return 400 for invalid status', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/messages/by-status/invalid`); + const res = await app.fetch(req); + assertEquals(res.status, 400); + }); + }); +}); diff --git a/tests/integration/routes/system-routes.test.ts b/tests/integration/routes/system-routes.test.ts new file mode 100644 index 0000000..7899412 --- /dev/null +++ b/tests/integration/routes/system-routes.test.ts @@ -0,0 +1,91 @@ +import { assertEquals } from 'jsr:@std/assert'; +import { beforeEach, describe, it } from 'jsr:@std/testing/bdd'; +import { SystemRoutes } from '../../../src/routes/system-routes.ts'; +import { AuthMiddleware } from '../../../src/services/auth-middleware.ts'; +import { Routes } from '../../../src/utils/routes.ts'; +import { VERSION_STRING } from '../../../src/version.ts'; + +describe('SystemRoutes integration tests', () => { + let routes: SystemRoutes; + let app: ReturnType; + const AUTH_TOKEN = 'test_token'; + + beforeEach(() => { + // Setup routes + routes = new SystemRoutes(); + app = Routes.initHono(); + + // Add auth middleware + app.use( + `/${VERSION_STRING}/*`, + AuthMiddleware.bearer({ + token: AUTH_TOKEN, + skipPaths: [`/${VERSION_STRING}/system/ping`], + }), + ); + + app.route(`/${VERSION_STRING}/system`, routes.getRoutes()); + }); + + describe('GET /ping', () => { + it('should return pong without authentication', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/system/ping`); + const res = await app.fetch(req); + + assertEquals(res.status, 200); + assertEquals(await res.text(), 'pong'); + }); + + it('should return pong even with invalid authentication', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/system/ping`, { + headers: { + 'Authorization': 'Bearer invalid_token', + }, + }); + const res = await app.fetch(req); + + assertEquals(res.status, 200); + assertEquals(await res.text(), 'pong'); + }); + }); + + describe('GET /health', () => { + it('should return 401 without authentication', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/system/health`); + const res = await app.fetch(req); + + assertEquals(res.status, 401); + }); + + it('should return 401 with invalid authentication', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/system/health`, { + headers: { + 'Authorization': 'Bearer invalid_token', + }, + }); + const res = await app.fetch(req); + + assertEquals(res.status, 401); + }); + + it('should return health status with valid authentication', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/system/health`, { + headers: { + 'Authorization': `Bearer ${AUTH_TOKEN}`, + }, + }); + const res = await app.fetch(req); + + assertEquals(res.status, 200); + + const body = await res.json(); + assertEquals(typeof body.status, 'string'); + assertEquals(body.status, 'healthy'); + assertEquals(typeof body.timestamp, 'string'); + + // Verify timestamp is a valid ISO string + const timestamp = new Date(body.timestamp); + assertEquals(isNaN(timestamp.getTime()), false, 'Timestamp should be a valid date'); + }); + }); +}); diff --git a/tests/integration/routes/turso-admin-logs-routes.test.ts b/tests/integration/routes/turso-admin-logs-routes.test.ts new file mode 100644 index 0000000..8426fb2 --- /dev/null +++ b/tests/integration/routes/turso-admin-logs-routes.test.ts @@ -0,0 +1,312 @@ +import { afterEach, beforeEach, describe, it } from 'jsr:@std/testing/bdd'; +import { expect } from 'jsr:@std/expect'; +import { Client } from 'libsql-core'; +import { TursoAdminRoutes } from '../../../src/routes/turso-admin-routes.ts'; +import { SqliteStore } from '../../../src/services/storage/sqlite-store.ts'; +import { TursoMessagesStore } from '../../../src/stores/turso/turso-messages-store.ts'; +import { TursoLogsStore } from '../../../src/stores/turso/turso-logs-store.ts'; +import { Migrations } from '../../../src/utils/migrations.ts'; +import { Routes } from '../../../src/utils/routes.ts'; +import { VERSION_STRING } from '../../../src/version.ts'; + +describe('Turso Admin Logs Routes', () => { + let sqlite: Client; + let sqliteStore: SqliteStore; + let app: ReturnType; + let messageStore: TursoMessagesStore; + let logsStore: TursoLogsStore; + + beforeEach(async () => { + // Set STORAGE_TYPE to TURSO for these tests + Deno.env.set('STORAGE_TYPE', 'TURSO'); + Deno.env.set('ENABLE_AUTH', 'false'); + + // Create in-memory SQLite for testing + sqliteStore = new SqliteStore({ url: ':memory:' }); + sqlite = await sqliteStore.getClient(); + + // Run migrations to set up tables + await new Migrations(sqliteStore).migrate({ force: true }); + + // Create stores directly + messageStore = new TursoMessagesStore(sqlite); + logsStore = new TursoLogsStore(sqlite); + + // Create admin routes + const adminRoutes = new TursoAdminRoutes(messageStore, logsStore, sqlite); + + app = Routes.initHono(); + app.route(`/${VERSION_STRING}/admin`, adminRoutes.getRoutes()); + }); + + afterEach(() => { + Deno.env.delete('STORAGE_TYPE'); + Deno.env.delete('ENABLE_AUTH'); + }); + + describe('GET /v1/admin/logs', () => { + it('should return empty array when no logs exist', async () => { + const response = await app.request(`/${VERSION_STRING}/admin/logs`); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(Array.isArray(data)).toBe(true); + expect(data).toHaveLength(0); + }); + + it('should return logs when they exist', async () => { + // Create a test message first + const messageResult = await messageStore.create({ + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com/webhook', + data: { test: 'data' }, + }, + status: 'CREATED', + publish_at: new Date(), + retried: 0, + }); + + if (messageResult.isErr()) throw new Error('Failed to create message'); + const message = messageResult.value; + + // Create logs for the message + await logsStore.create({ + type: 'CREATE', + object: 'message', + message_id: message.id, + before_data: {}, + after_data: { id: message.id, status: 'CREATED' }, + created_at: new Date(), + }); + + await logsStore.create({ + type: 'UPDATE', + object: 'message', + message_id: message.id, + before_data: { id: message.id, status: 'CREATED' }, + after_data: { id: message.id, status: 'QUEUED' }, + created_at: new Date(), + }); + + const response = await app.request(`/${VERSION_STRING}/admin/logs`); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(Array.isArray(data)).toBe(true); + expect(data).toHaveLength(2); + + // Should be ordered by created_at DESC + expect(data[0].type).toBe('UPDATE'); + expect(data[1].type).toBe('CREATE'); + + // Verify log structure + expect(data[0]).toMatchObject({ + type: 'UPDATE', + object: 'message', + message_id: message.id, + before_data: { id: message.id, status: 'CREATED' }, + after_data: { id: message.id, status: 'QUEUED' }, + }); + }); + }); + + describe('GET /v1/admin/log/:messageId', () => { + it('should return empty logs array for non-existent message', async () => { + const response = await app.request(`/${VERSION_STRING}/admin/log/msg_nonexistent`); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.messageId).toBe('msg_nonexistent'); + expect(Array.isArray(data.logs)).toBe(true); + expect(data.logs).toHaveLength(0); + }); + + it('should return logs for a specific message', async () => { + // Create test message + const messageResult = await messageStore.create({ + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com/webhook', + data: { test: 'data' }, + }, + status: 'CREATED', + publish_at: new Date(), + retried: 0, + }); + + if (messageResult.isErr()) throw new Error('Failed to create message'); + const message = messageResult.value; + + // Create logs for this message + await logsStore.create({ + type: 'CREATE', + object: 'message', + message_id: message.id, + before_data: {}, + after_data: { id: message.id, status: 'CREATED' }, + created_at: new Date(Date.now() - 2000), + }); + + await logsStore.create({ + type: 'UPDATE', + object: 'message', + message_id: message.id, + before_data: { id: message.id, status: 'CREATED' }, + after_data: { id: message.id, status: 'QUEUED' }, + created_at: new Date(Date.now() - 1000), + }); + + // Create log for a different message (should not be returned) + const otherMessageResult = await messageStore.create({ + payload: { + headers: { forward: {}, command: {} }, + url: 'https://other.com/webhook', + data: { other: 'data' }, + }, + status: 'CREATED', + publish_at: new Date(), + retried: 0, + }); + + if (otherMessageResult.isErr()) throw new Error('Failed to create other message'); + const otherMessage = otherMessageResult.value; + + await logsStore.create({ + type: 'CREATE', + object: 'message', + message_id: otherMessage.id, + before_data: {}, + after_data: { id: otherMessage.id, status: 'CREATED' }, + created_at: new Date(), + }); + + const response = await app.request(`/${VERSION_STRING}/admin/log/${message.id}`); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.messageId).toBe(message.id); + expect(Array.isArray(data.logs)).toBe(true); + expect(data.logs).toHaveLength(2); + + // Should be ordered by created_at ASC (chronological order) + expect(data.logs[0].type).toBe('CREATE'); + expect(data.logs[1].type).toBe('UPDATE'); + + // Verify only logs for requested message are returned + data.logs.forEach((log: { message_id: string }) => { + expect(log.message_id).toBe(message.id); + }); + }); + }); + + describe('DELETE /v1/admin/reset/logs', () => { + it('should reset only logs table', async () => { + // Create test message and logs + const messageResult = await messageStore.create({ + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com/webhook', + data: { test: 'data' }, + }, + status: 'CREATED', + publish_at: new Date(), + retried: 0, + }); + + if (messageResult.isErr()) throw new Error('Failed to create message'); + const message = messageResult.value; + + await logsStore.create({ + type: 'CREATE', + object: 'message', + message_id: message.id, + before_data: {}, + after_data: { id: message.id, status: 'CREATED' }, + created_at: new Date(), + }); + + // Verify logs exist + let logsResponse = await app.request(`/${VERSION_STRING}/admin/logs`); + let logsData = await logsResponse.json(); + expect(logsData).toHaveLength(1); + + // Reset logs + const resetResponse = await app.request(`/${VERSION_STRING}/admin/reset/logs`, { + method: 'DELETE', + }); + expect(resetResponse.status).toBe(200); + + const resetData = await resetResponse.json(); + expect(resetData.message).toBe('Logs table reset!'); + expect(resetData.match).toBe('logs'); + + // Verify logs are gone but message still exists + logsResponse = await app.request(`/${VERSION_STRING}/admin/logs`); + logsData = await logsResponse.json(); + expect(logsData).toHaveLength(0); + + const messageResponse = await messageStore.fetchOne(message.id); + expect(messageResponse.isOk()).toBe(true); + }); + }); + + describe('DELETE /v1/admin/reset (all)', () => { + it('should reset both messages and logs tables', async () => { + // Create test message and logs + const messageResult = await messageStore.create({ + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com/webhook', + data: { test: 'data' }, + }, + status: 'CREATED', + publish_at: new Date(), + retried: 0, + }); + + if (messageResult.isErr()) throw new Error('Failed to create message'); + const message = messageResult.value; + + await logsStore.create({ + type: 'CREATE', + object: 'message', + message_id: message.id, + before_data: {}, + after_data: { id: message.id, status: 'CREATED' }, + created_at: new Date(), + }); + + // Reset all + const resetResponse = await app.request(`/${VERSION_STRING}/admin/reset`, { + method: 'DELETE', + }); + expect(resetResponse.status).toBe(200); + + const resetData = await resetResponse.json(); + expect(resetData.message).toBe('Messages and logs tables reset!'); + expect(resetData.match).toBe('all'); + + // Verify both are gone + const logsResponse = await app.request(`/${VERSION_STRING}/admin/logs`); + const logsData = await logsResponse.json(); + expect(logsData).toHaveLength(0); + + const messageResponse = await messageStore.fetchOne(message.id); + expect(messageResponse.isErr()).toBe(true); + }); + }); + + describe('Error handling', () => { + it('should handle database errors gracefully', async () => { + // Close the database connection to simulate error + sqlite.close(); + + const response = await app.request(`/${VERSION_STRING}/admin/logs`); + expect(response.status).toBe(500); + + const data = await response.json(); + expect(data.error).toBe('Failed to retrieve logs'); + }); + }); +}); diff --git a/tests/integration/routes/turso-admin-routes.test.ts b/tests/integration/routes/turso-admin-routes.test.ts new file mode 100644 index 0000000..15a7238 --- /dev/null +++ b/tests/integration/routes/turso-admin-routes.test.ts @@ -0,0 +1,312 @@ +import { assertEquals, assertExists } from 'jsr:@std/assert'; +import { afterEach, beforeEach, describe, it } from 'jsr:@std/testing/bdd'; +import { z } from 'zod'; +import { Client } from 'libsql-core'; +import { TursoAdminRoutes } from '../../../src/routes/turso-admin-routes.ts'; +import { MessageSchema } from '../../../src/schemas/message-schema.ts'; +import { AuthMiddleware } from '../../../src/services/auth-middleware.ts'; +import { SqliteStore } from '../../../src/services/storage/sqlite-store.ts'; +import { TursoMessagesStore } from '../../../src/stores/turso/turso-messages-store.ts'; +import { TursoLogsStore } from '../../../src/stores/turso/turso-logs-store.ts'; +import { Routes } from '../../../src/utils/routes.ts'; +import { Migrations } from '../../../src/utils/migrations.ts'; +import { VERSION_STRING } from '../../../src/version.ts'; + +describe('TursoAdminRoutes integration tests', () => { + let sqliteStore: SqliteStore; + let sqlite: Client; + let messageStore: TursoMessagesStore; + let adminRoutes: TursoAdminRoutes; + let app: ReturnType; + + beforeEach(async () => { + sqliteStore = new SqliteStore({ url: ':memory:' }); + sqlite = await sqliteStore.getClient(); + + // Run migrations to create tables + await new Migrations(sqliteStore).migrate({ force: true }); + + messageStore = new TursoMessagesStore(sqlite); + const logsStore = new TursoLogsStore(sqlite); + adminRoutes = new TursoAdminRoutes(messageStore, logsStore, sqlite); + + // Set up auth token for tests + Deno.env.set('AUTH_TOKEN', 'test-token'); + + // Set up app with routes and auth + app = Routes.initHono(); + app.use( + `/${VERSION_STRING}/*`, + AuthMiddleware.bearer({ + token: 'test-token', + skipPaths: [], + }), + ); + app.route(adminRoutes.getBasePath(VERSION_STRING), adminRoutes.getRoutes()); + }); + + afterEach(() => { + sqlite.close(); + console.log('resetting turso store'); + }); + + describe('GET /admin/stats', () => { + it('should return empty stats when no data exists', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/stats`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertExists(body.stats); + assertEquals(body.stats['messages/total'], 0); + }); + + it('should return stats with message data', async () => { + // Create some test messages + const message1: z.infer = { + id: 'msg_test1', + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED', + created_at: new Date(), + updated_at: new Date(), + }; + + const message2: z.infer = { + id: 'msg_test2', + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'QUEUED', + created_at: new Date(), + updated_at: new Date(), + }; + + await messageStore.create(message1); + await messageStore.create(message2); + + const req = new Request(`http://localhost/${VERSION_STRING}/admin/stats`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertExists(body.stats); + assertEquals(body.stats['messages/total'], 2); + assertEquals(body.stats['messages/CREATED'], 1); + assertEquals(body.stats['messages/QUEUED'], 1); + }); + }); + + describe('GET /admin/raw', () => { + it('should return messages table data', async () => { + // Create a test message + const message: z.infer = { + id: 'msg_test1', + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED', + created_at: new Date(), + updated_at: new Date(), + }; + + await messageStore.create(message); + + const req = new Request(`http://localhost/${VERSION_STRING}/admin/raw/messages`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertEquals(Array.isArray(body), true); + assertEquals(body.length, 1); + assertEquals(body[0].table, 'messages'); + assertExists(body[0].data); + }); + + it('should return migrations table data', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/raw/migrations`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertEquals(Array.isArray(body), true); + // Should have at least the migration entries + assertEquals(body.length >= 2, true); + assertEquals(body[0].table, 'migrations'); + }); + + it('should return error for unknown table', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/raw/unknown`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 400); + + const body = await res.json(); + assertEquals(body.message.includes('Unknown table'), true); + }); + + it('should return messages by default when no match specified', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/raw`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertEquals(Array.isArray(body), true); + }); + }); + + describe('GET /admin/logs', () => { + it('should return empty logs array when no logs exist', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/logs`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertEquals(Array.isArray(body), true); + assertEquals(body.length, 0); + }); + }); + + describe('GET /admin/log/:messageId', () => { + it('should return empty logs for non-existent message', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/log/msg_test`, { + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertExists(body.messageId); + assertEquals(body.messageId, 'msg_test'); + assertEquals(Array.isArray(body.logs), true); + assertEquals(body.logs.length, 0); + }); + }); + + describe('DELETE /admin/reset', () => { + it('should reset messages table', async () => { + // Create a test message + const message: z.infer = { + id: 'msg_test1', + payload: { + headers: { forward: {}, command: {} }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED', + created_at: new Date(), + updated_at: new Date(), + }; + + await messageStore.create(message); + + const req = new Request(`http://localhost/${VERSION_STRING}/admin/reset/messages`, { + method: 'DELETE', + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertEquals(body.message, 'Messages and logs tables reset!'); + assertEquals(body.match, 'messages'); + + // Verify messages were deleted + const fetchResult = await messageStore.fetchOne('msg_test1'); + assertEquals(fetchResult.isErr(), true); + }); + + it('should reset all messages when no match specified', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/reset`, { + method: 'DELETE', + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + assertEquals(body.message, 'Messages and logs tables reset!'); + assertEquals(body.match, 'all'); + }); + + it('should reject migration table reset', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/reset/migrations`, { + method: 'DELETE', + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 400); + + const body = await res.json(); + assertEquals(body.message.includes('Cannot reset migrations table'), true); + }); + + it('should return error for unknown table', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/admin/reset/unknown`, { + method: 'DELETE', + headers: { + 'Authorization': 'Bearer test-token', + }, + }); + + const res = await app.fetch(req); + assertEquals(res.status, 400); + + const body = await res.json(); + assertEquals(body.message.includes('Unknown table'), true); + }); + }); +}); diff --git a/tests/integration/routes/turso-message-routes.test.ts b/tests/integration/routes/turso-message-routes.test.ts new file mode 100644 index 0000000..9cf73e7 --- /dev/null +++ b/tests/integration/routes/turso-message-routes.test.ts @@ -0,0 +1,191 @@ +import { assert, assertEquals, assertExists } from 'jsr:@std/assert'; +import { afterEach, beforeEach, describe, it } from 'jsr:@std/testing/bdd'; +import { Client } from 'libsql-core'; +import { z } from 'zod'; +import { MessageRoutes } from '../../../src/routes/message-routes.ts'; +import { MessageReceivedResponseSchema, MessageResponseSchema, MessageSchema } from '../../../src/schemas/message-schema.ts'; +import { SqliteStore } from '../../../src/services/storage/sqlite-store.ts'; +import { KvUtilStore } from '../../../src/stores/kv/kv-util-store.ts'; +import { TursoMessagesStore } from '../../../src/stores/turso/turso-messages-store.ts'; +import { Migrations } from '../../../src/utils/migrations.ts'; +import { Routes } from '../../../src/utils/routes.ts'; +import { VERSION_STRING } from '../../../src/version.ts'; + +describe('TursoMessageRoutes integration tests', () => { + let kv: Deno.Kv; + let client: Client; + let sqliteStore: SqliteStore; + let messageStore: TursoMessagesStore; + let routes: MessageRoutes; + let app: ReturnType; + + beforeEach(async () => { + kv = await Deno.openKv(); + sqliteStore = new SqliteStore({ url: ':memory:' }); + client = await sqliteStore.getClient(); + + await new Migrations(sqliteStore).migrate({ force: true }); + messageStore = new TursoMessagesStore(client); + + // Setup routes + routes = new MessageRoutes(kv, messageStore); + app = Routes.initHono(); + app.route(`/${VERSION_STRING}/messages`, routes.getRoutes()); + }); + + afterEach(async () => { + await new KvUtilStore(kv).reset(); + kv.close(); + }); + + describe('POST /:url', () => { + it('should create a new message', async () => { + const payload = { test: true }; + const req = new Request(`http://localhost/${VERSION_STRING}/messages/https://example.com/callback`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + const res = await app.fetch(req); + assertEquals(res.status, 201); + + const body = await res.json(); + const validate = MessageReceivedResponseSchema.safeParse(body); + assertEquals(validate.success, true, `Invalid response body: ${JSON.stringify(body)}`); + assertExists(validate.data); + assertExists(validate.data.id); + assertExists(validate.data.publish_at); + assertEquals(validate.data.id.startsWith('msg_'), true); + }); + + it('should create a new message with delayed publish date', async () => { + const payload = { test: true }; + const req = new Request(`http://localhost/${VERSION_STRING}/messages/https://example.com/callback`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Done-Delay': '5s', + }, + body: JSON.stringify(payload), + }); + + const res = await app.fetch(req); + assertEquals(res.status, 201); + + const body = await res.json(); + const validate = MessageReceivedResponseSchema.safeParse(body); + assertEquals(validate.success, true, `Invalid response body: ${JSON.stringify(body)}`); + assertExists(validate.data); + assertExists(validate.data.id); + assertExists(validate.data.publish_at); + assertEquals(validate.data.id.startsWith('msg_'), true); + + // Verify the publish date is in the future + const publishAt = new Date(validate.data.publish_at); + const now = new Date(); + assertEquals(publishAt > now, true); + }); + }); + + describe('GET /:id', () => { + it('should fetch a message by id', async () => { + // Create a test message + const message: z.infer = { + id: 'msg_test1', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED', + created_at: new Date(), + updated_at: new Date(), + }; + + await messageStore.create(message); + + // Make request + const req = new Request(`http://localhost/${VERSION_STRING}/messages/msg_test1`); + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + const validate = MessageResponseSchema.safeParse(body); + + assert(validate.success, `Invalid response body: ${JSON.stringify(body)}`); + assertExists(validate.data, `Missing message data`); + assertEquals(validate.data.id, message.id, `Invalid message id: ${validate.data.id}`); + assertEquals(validate.data.status, message.status, `Invalid message status: ${validate.data.status}`); + }); + + it('should return 404 for non-existent message', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/messages/non_existent`); + const res = await app.fetch(req); + assertEquals(res.status, 404); + }); + }); + + describe('GET /by-status/:status', () => { + it('should fetch messages by status', async () => { + // Create test messages + const message1: z.infer = { + id: 'msg_test1', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'QUEUED', + created_at: new Date(), + updated_at: new Date(), + }; + + const message2: z.infer = { + id: 'msg_test2', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'QUEUED', + created_at: new Date(), + updated_at: new Date(), + }; + + await messageStore.create(message1); + await messageStore.create(message2); + + // Make request + const req = new Request(`http://localhost/${VERSION_STRING}/messages/by-status/queued`); + const res = await app.fetch(req); + assertEquals(res.status, 200); + + const body = await res.json(); + const validate = z.array(MessageResponseSchema).safeParse(body); + + assertEquals(validate.success, true, `Invalid response body: ${JSON.stringify(body)}`); + assertExists(validate.data, `Missing message data`); + assertEquals(validate.data.length, 2, `Invalid number of messages: ${validate.data.length}`); + assertEquals(validate.data[0].status, 'QUEUED', `Invalid message status: ${validate.data[0].status}`); + assertEquals(validate.data[1].status, 'QUEUED', `Invalid message status: ${validate.data[1].status}`); + }); + + it('should return 400 for invalid status', async () => { + const req = new Request(`http://localhost/${VERSION_STRING}/messages/by-status/invalid`); + const res = await app.fetch(req); + assertEquals(res.status, 400); + }); + }); +}); diff --git a/tests/stores/kv-messages-store.test.ts b/tests/stores/kv-messages-store.test.ts new file mode 100644 index 0000000..180c12c --- /dev/null +++ b/tests/stores/kv-messages-store.test.ts @@ -0,0 +1,273 @@ +import { assertEquals, assertExists } from 'jsr:@std/assert'; +import { afterEach, beforeEach, describe, it } from 'jsr:@std/testing/bdd'; +import { MessageData, MessageModel, MessageReceivedData } from '../../src/stores/kv/kv-message-model.ts'; +import { KvMessagesStore } from '../../src/stores/kv/kv-messages-store.ts'; +import { KvUtilStore } from '../../src/stores/kv/kv-util-store.ts'; +import { Dates } from '../../src/utils/dates.ts'; + +describe('KvMessagesStore integration tests', () => { + let store: KvMessagesStore; + let kv: Deno.Kv; + + beforeEach(async () => { + kv = await Deno.openKv(); + store = new KvMessagesStore(kv); + }); + + afterEach(async () => { + await new KvUtilStore(kv).reset(); + kv.close(); + }); + + describe('create()', () => { + it('should create a new message', async () => { + const message: MessageData = { + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + data: { test: true }, + }, + publish_at: new Date(), + status: 'CREATED', + }; + + const result = await store.create(message); + assertEquals(result.isOk(), true, 'should create a new message'); + + if (result.isOk()) { + const created = result.value; + assertEquals(created.status, message.status, 'should have the correct status'); + assertExists(created.created_at, 'should have a created_at'); + assertEquals(created.id.startsWith('msg_'), true, 'should have a valid id'); + } + }); + + it('should create a new message with a custom id', async () => { + const message: MessageData = { + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + data: { test: true }, + }, + publish_at: new Date(), + status: 'CREATED', + }; + + const result = await store.create(message, { withId: 'msg_test1' }); + assertEquals(result.isOk(), true, 'should create a new message'); + + if (result.isOk()) { + const created = result.value; + assertEquals(created.status, message.status, 'should have the correct status'); + assertExists(created.created_at, 'should have a created_at'); + assertEquals(created.id, 'msg_test1', 'should have the correct id'); + } + }); + }); + + describe('fetch()', () => { + it('should fetch a message by id', async () => { + const message: MessageData = { + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED', + }; + + const createResult = await store.create(message); + assertEquals(createResult.isOk(), true); + + const result = await store.fetchOne(createResult.value.id); + assertEquals(result.isOk(), true); + + if (result.isOk()) { + assertEquals(result.value.id, createResult.value.id); + assertEquals(result.value.status, message.status); + } + }); + + it('should return error for non-existent message', async () => { + const result = await store.fetchOne('non_existent'); + assertEquals(result.isErr(), true); + }); + }); + + describe('fetchByStatus()', () => { + it('should fetch messages by status', async () => { + const message1: MessageData = { + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'QUEUED', + }; + + const message2: MessageData = { + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'ARCHIVED', + }; + + const createResult1 = await store.create(message1); + assertEquals(createResult1.isOk(), true); + + const createResult2 = await store.create(message2); + assertEquals(createResult2.isOk(), true); + + const result = await store.fetchByStatus('QUEUED'); + assertEquals(result.isOk(), true); + + if (result.isOk()) { + assertEquals(result.value.length, 1, 'should fetch 1 message'); + assertEquals(result.value[0].id, createResult1.value.id, 'should have the correct id for the first message'); + assertEquals(result.value[0].status, 'QUEUED', 'should have the correct status for the first message'); + } + }); + }); + + describe('fetchByDate()', () => { + it('should fetch messages by publish_at', async () => { + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const message1: MessageData = { + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: today, + status: 'CREATED', + }; + + const message2: MessageData = { + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: tomorrow, + status: 'CREATED', + }; + + await store.create(message1, { withId: 'msg_test5' }); + await store.create(message2, { withId: 'msg_test6' }); + + const result = await store.fetchByDate(today); + assertEquals(result.isOk(), true, 'should fetch messages by publish_at'); + + if (result.isOk()) { + assertEquals(result.value.length, 1); + assertEquals(Dates.getDateOnly(result.value[0].publish_at), Dates.getDateOnly(today)); + } + }); + }); + + describe('update()', () => { + it('should update a message', async () => { + const message: MessageData = { + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED', + }; + + const createResult = await store.create(message); + assertEquals(createResult.isOk(), true); + + const updateResult = await store.update(createResult.value.id, { + payload: message.payload, + publish_at: message.publish_at, + status: 'QUEUED', + }); + + assertEquals(updateResult.isOk(), true); + + if (updateResult.isOk()) { + assertEquals(updateResult.value.status, 'QUEUED'); + } + }); + }); + + describe('delete()', () => { + it('should delete a message', async () => { + const message: MessageData = { + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED', + }; + + const createResult = await store.create(message, { withId: 'msg_test8' }); + assertEquals(createResult.isOk(), true); + + const deleteResult = await store.delete(createResult.value.id); + assertEquals(deleteResult.isOk(), true); + + const fetchResult = await store.fetchOne(createResult.value.id); + assertEquals(fetchResult.isErr(), true); + }); + }); + + describe('createFromReceivedData()', () => { + it('should create a message from received data', async () => { + const receivedData: MessageReceivedData = { + id: 'msg_test9', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + }; + + const result = await store.createFromReceivedData(receivedData); + assertEquals(result.isOk(), true); + + if (result.isOk()) { + const created = result.value as unknown as MessageModel; + assertEquals(created.id, receivedData.id); + assertEquals(created.status, 'CREATED'); + assertEquals(created.payload, receivedData.payload); + } + }); + }); +}); diff --git a/tests/stores/turso-messages-store.test.ts b/tests/stores/turso-messages-store.test.ts new file mode 100644 index 0000000..a7f1f82 --- /dev/null +++ b/tests/stores/turso-messages-store.test.ts @@ -0,0 +1,234 @@ +import { assertEquals, assertExists } from 'jsr:@std/assert'; +import { beforeEach, describe, it } from 'jsr:@std/testing/bdd'; +import { Client } from 'libsql-core'; +import { SqliteStore } from '../../src/services/storage/sqlite-store.ts'; +import { MESSAGE_STATUS, MessageModel } from '../../src/stores/kv/kv-message-model.ts'; +import { TursoMessagesStore } from '../../src/stores/turso/turso-messages-store.ts'; +import { Dates } from '../../src/utils/dates.ts'; +import { Migrations } from '../../src/utils/migrations.ts'; + +describe('TursoMessagesStore integration tests', () => { + let client: Client; + let store: TursoMessagesStore; + let sqliteStore: SqliteStore; + + beforeEach(async () => { + sqliteStore = new SqliteStore({ url: ':memory:' }); + client = await sqliteStore.getClient(); + + await new Migrations(sqliteStore).migrate({ force: true }); + store = new TursoMessagesStore(client); + }); + + describe('create()', () => { + it('should create a new message', async () => { + const message: MessageModel = { + id: 'msg_test1', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + data: { test: true }, + }, + publish_at: new Date(), + status: 'CREATED' as MESSAGE_STATUS, + created_at: new Date(), + updated_at: new Date(), + }; + + const result = await store.create(message); + assertEquals(result.isOk(), true); + + if (result.isOk()) { + assertEquals(result.value.id, message.id); + assertEquals(result.value.status, message.status); + assertExists(result.value.created_at); + } + }); + }); + + describe('fetch()', () => { + it('should fetch a message by id', async () => { + const message: MessageModel = { + id: 'msg_test2', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED' as MESSAGE_STATUS, + created_at: new Date(), + updated_at: new Date(), + }; + + await store.create(message); + const result = await store.fetchOne(message.id); + + assertEquals(result.isOk(), true); + if (result.isOk()) { + assertEquals(result.value.id, message.id); + assertEquals(result.value.status, message.status); + } + }); + + it('should return error for non-existent message', async () => { + const result = await store.fetchOne('non_existent'); + assertEquals(result.isErr(), true); + }); + }); + + describe('fetchByStatus()', () => { + it('should fetch messages by status', async () => { + const message1: MessageModel = { + id: 'msg_test3', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'QUEUED' as MESSAGE_STATUS, + created_at: new Date(), + updated_at: new Date(), + }; + + const message2: MessageModel = { + id: 'msg_test4', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'QUEUED' as MESSAGE_STATUS, + created_at: new Date(), + updated_at: new Date(), + }; + + await store.create(message1); + await store.create(message2); + + const result = await store.fetchByStatus('QUEUED'); + assertEquals(result.isOk(), true); + if (result.isOk()) { + assertEquals(result.value.length, 2); + assertEquals(result.value[0].status, 'QUEUED'); + } + }); + }); + + describe('fetchByDate()', () => { + it('should fetch messages by publish date', async () => { + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const message1: MessageModel = { + id: 'msg_test5', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: today, + status: 'CREATED' as MESSAGE_STATUS, + created_at: new Date(), + updated_at: new Date(), + }; + + const message2: MessageModel = { + id: 'msg_test6', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: tomorrow, + status: 'CREATED' as MESSAGE_STATUS, + created_at: new Date(), + updated_at: new Date(), + }; + + await store.create(message1); + await store.create(message2); + + const result = await store.fetchByDate(today); + assertEquals(result.isOk(), true); + if (result.isOk()) { + assertEquals(result.value.length, 1); + assertEquals(Dates.getDateOnly(result.value[0].publish_at), Dates.getDateOnly(today)); + } + }); + }); + + describe('update()', () => { + it('should update a message', async () => { + const message: MessageModel = { + id: 'msg_test7', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED' as MESSAGE_STATUS, + created_at: new Date(), + updated_at: new Date(), + }; + + await store.create(message); + + const updateResult = await store.update(message.id, { + payload: message.payload, + publish_at: message.publish_at, + status: 'QUEUED' as MESSAGE_STATUS, + }); + + assertEquals(updateResult.isOk(), true); + if (updateResult.isOk()) { + assertEquals(updateResult.value.status, 'QUEUED'); + } + }); + }); + + describe('delete()', () => { + it('should delete a message', async () => { + const message: MessageModel = { + id: 'msg_test8', + payload: { + headers: { + forward: {}, + command: {}, + }, + url: 'https://example.com', + }, + publish_at: new Date(), + status: 'CREATED' as MESSAGE_STATUS, + created_at: new Date(), + updated_at: new Date(), + }; + + await store.create(message); + const deleteResult = await store.delete(message.id); + assertEquals(deleteResult.isOk(), true); + + const fetchResult = await store.fetchOne(message.id); + assertEquals(fetchResult.isErr(), true); + }); + }); +}); diff --git a/tests/test_utils.ts b/tests/test_utils.ts new file mode 100644 index 0000000..ce3d52c --- /dev/null +++ b/tests/test_utils.ts @@ -0,0 +1,49 @@ +import { SYSTEM_MESSAGE_TYPE, SystemMessage } from '../src/services/storage/kv-store.ts'; + +/** + * Creates a test message for queue operations + */ +export function createTestMessage(): SystemMessage { + return { + id: `test_${crypto.randomUUID()}`, + type: SYSTEM_MESSAGE_TYPE.MESSAGE_RECEIVED, + data: { test: 'data' }, + object: 'test', + created_at: new Date(), + }; +} + +/** + * Waits for the specified number of milliseconds + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Generates a random key for testing + */ +export function randomKey(): string[] { + return [`test_${crypto.randomUUID()}`]; +} + +/** + * Generates a random value for testing + */ +export function randomValue(): { test: string } { + return { test: crypto.randomUUID() }; +} + +/** + * Remove test database file + */ +export function removeDbFile(path: string): void { + try { + Deno.removeSync(path); + } catch (e) { + // Ignore if file doesn't exist + if (!(e instanceof Deno.errors.NotFound)) { + console.error(`Failed to remove test database: ${e}`); + } + } +}