diff --git a/.env.example b/.env.example index ecee03c..92d54a0 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,35 @@ -# Production Environment Variables Template -# Copy this file to .env and fill in your production values -# NEVER commit .env to version control! +# MeshHook environment template +# Copy to .env and fill in real values. NEVER commit .env. -# Supabase Production -DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres -SUPABASE_URL=https://[YOUR-PROJECT-REF].supabase.co -SUPABASE_ANON_KEY=[YOUR-ANON-KEY] -SUPABASE_SERVICE_ROLE_KEY=[YOUR-SERVICE-ROLE-KEY] +# --------------------------------------------------------------------------- +# Database (Turso / libSQL) +# --------------------------------------------------------------------------- +# Local development — a plain SQLite file, no server required: +# TURSO_DATABASE_URL=file:./meshhook.db +# +# Production — create the database and a token with the Turso CLI: +# turso db create meshhook +# turso db show meshhook --url +# turso db tokens create meshhook +TURSO_DATABASE_URL=libsql://your-database-your-org.turso.io +TURSO_AUTH_TOKEN=your-turso-auth-token +# --------------------------------------------------------------------------- +# Secrets vault +# --------------------------------------------------------------------------- +# Encrypts the secrets table with AES-256-GCM. Generate with: +# openssl rand -hex 32 +# Losing this key makes every stored secret unrecoverable; rotating it requires +# re-encrypting existing rows. +SECRETS_ENCRYPTION_KEY= + +# --------------------------------------------------------------------------- +# Integrations +# --------------------------------------------------------------------------- OPENAI_API_KEY=your-openai-api-key -# Application Settings +# --------------------------------------------------------------------------- +# Application +# --------------------------------------------------------------------------- NODE_ENV=production -PORT=8080 \ No newline at end of file +PORT=8080 diff --git a/.gitignore b/.gitignore index 02adca8..afcab01 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,4 @@ Thumbs.db # Test coverage coverage/ .nyc_output/ -pnpm-lock.yaml .env.local diff --git a/README.md b/README.md index b3d1067..523e80e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # MeshHook — Mesh your webhooks. Orchestrate everything. -**MeshHook** is an MIT-licensed, webhook-first workflow engine with a visual builder (SvelteKit/Svelte 5) and Temporal-like durability via **event sourcing on Postgres (Supabase)**. +**MeshHook** is an MIT-licensed, webhook-first workflow engine with a visual builder (SvelteKit/Svelte 5) and Temporal-like durability via **event sourcing on SQLite (Turso)**. ## Stack - **UI/API**: SvelteKit (Svelte 5) -- **DB/Queues/Realtime/Storage**: Supabase (Postgres, Realtime, Storage) +- **Database**: Turso (libSQL/SQLite) — embeddable locally, replicated in production - **Workers**: Node.js (or Bun), stateless -- **Queue**: pg-boss or pgmq (Postgres-native) +- **Queue**: SQLite-backed, with visibility timeouts and a dead-letter queue +- **Auth**: self-hosted sessions (scrypt password hashing, opaque session tokens) +- **Live logs**: Server-Sent Events - **Transforms**: JMESPath ## Why MeshHook? @@ -14,13 +16,13 @@ |---------|-----|----------|----------|----------| | **License** | Fair-code (restrictive) | AGPLv3 | MIT | **MIT** | | **Primary Use Case** | No-code automation | Script orchestration | Microservice workflows | **Webhook-first workflows** | -| **Durability** | Database polling | Database + queues | Custom event sourcing | **Event sourcing on Postgres** | +| **Durability** | Database polling | Database + queues | Custom event sourcing | **Event sourcing on SQLite** | | **Visual Builder** | ✅ Drag-and-drop | ❌ Code-first | ❌ Code-first | **✅ Visual + Code** | | **Webhook-Native** | ⚠️ Supported | ⚠️ Supported | ❌ Not primary | **✅ Built-in** | | **Self-Hosted** | ✅ Yes | ✅ Yes | ✅ Yes | **✅ Yes** | -| **Database** | MySQL/Postgres | Postgres | Custom | **Postgres (Supabase)** | +| **Database** | MySQL/Postgres | Postgres | Custom | **SQLite (Turso)** | | **Transforms** | JavaScript | TypeScript/Python | Any language | **JMESPath** | -| **Realtime Logs** | ❌ Polling | ❌ Polling | ❌ Polling | **✅ Supabase Realtime** | +| **Realtime Logs** | ❌ Polling | ❌ Polling | ❌ Polling | **✅ Server-Sent Events** | | **Deployment** | Complex (multiple services) | Complex (workers + API) | Complex (server + workers) | **Simple (single service)** | | **Learning Curve** | Low (no-code) | Medium (scripts) | High (SDK required) | **Low (visual + simple)** | @@ -30,7 +32,7 @@ - **MIT licensed**: Truly open source, use anywhere without restrictions - **Event sourcing**: Temporal-like durability without the complexity - **Visual + Code**: Low-code visual builder with JMESPath for power users -- **Postgres-native**: Leverage Supabase for database, queues, realtime, and storage +- **No infrastructure**: local development is a single SQLite file — no server, no containers ## Quick Start @@ -46,19 +48,17 @@ ```bash pnpm run setup ``` - Select "Local Development" when prompted. This creates `.env.local` with Supabase local defaults. + Select "Local Development" when prompted. This creates `.env.local` pointing at a + local SQLite file (`file:./meshhook.db`) and generates a secrets encryption key. -3. **Start Supabase locally** - ```bash - pnpx supabase start - ``` + There is no database server to start — the file is created by the migration step. -4. **Run migrations** +3. **Run migrations** ```bash pnpm run db:migrate ``` -5. **Start the orchestrator** +4. **Start the orchestrator** ```bash pnpm run start ``` @@ -75,13 +75,20 @@ ```bash pnpm run setup ``` - Select "Production" or "Staging" and enter your Supabase credentials. + Select "Production" or "Staging" and enter your Turso database URL and auth token. + Create them first with the Turso CLI: + ```bash + turso db create meshhook + turso db show meshhook --url + turso db tokens create meshhook + ``` 3. **Run migrations** ```bash pnpm run db:migrate ``` - Automatically detects environment and pushes migrations to remote database. + Applies any pending migrations from `migrations/` and records them in + `schema_migrations`. Re-running is a no-op. 4. **Start the server** ```bash @@ -92,7 +99,10 @@ ## Available Commands - `pnpm run setup` - Interactive environment configuration (local/staging/production) -- `pnpm run db:migrate` - Run database migrations (auto-detects environment) +- `pnpm run db:migrate` - Apply pending database migrations +- `pnpm run db:status` - Show applied and pending migrations +- `pnpm run db:verify` - Verify the schema matches what the app expects +- `pnpm test` - Run the test suite - `pnpm run start` - Start the orchestrator worker (production) - `pnpm mh --help` - CLI help @@ -106,7 +116,7 @@ ## Documentation - [`./docs/Environment-Setup.md`](./docs/Environment-Setup.md) - Detailed environment setup guide -- [`./docs/Event-Partitioning.md`](./docs/Event-Partitioning.md) - Event partitioning strategy and maintenance +- [`./docs/Turso-Migration.md`](./docs/Turso-Migration.md) - What changed in the move off Supabase - [`./docs/PRD.md`](./docs/PRD.md) - Product requirements - [`./docs/Architecture.md`](./docs/Architecture.md) - System architecture - [`./docs/diagrams/*.puml`](./docs/diagrams/) - PlantUML diagrams @@ -119,4 +129,4 @@ MeshHook runs as a single service on port 8080 that handles: - Background job execution - HTTP request execution with retries -All components communicate via Supabase (Postgres) - no inter-service HTTP calls needed. +All components communicate through the Turso database - no inter-service HTTP calls needed. diff --git a/TODO.md b/TODO.md index f4d0bee..233a076 100644 --- a/TODO.md +++ b/TODO.md @@ -1,259 +1,254 @@ # MeshHook TODO -Based on [PRD.md](./docs/PRD.md) - v1 Implementation Roadmap - -## ✅ Phase 0: Foundation (COMPLETED) -- [x] Environment configuration (local/staging/production) -- [x] Supabase CLI integration -- [x] Database connection setup -- [x] Unified setup and migration scripts -- [x] Documentation (README, Environment-Setup) - -## 🚧 Phase 1: Core Infrastructure - -### Database Schema -- [ ] Create core tables migration - - [ ] `projects` - Multi-tenant project isolation - - [ ] `workflows` - Workflow definitions with versioning - - [ ] `workflow_versions` - Immutable published versions - - [ ] `runs` - Workflow execution instances - - [ ] `events` - Event sourcing log - - [ ] `secrets` - Encrypted secrets vault - - [ ] `audit_log` - Admin actions and secret access -- [ ] Implement Row Level Security (RLS) policies -- [ ] Create indices for hot paths -- [ ] Set up event partitioning - -### Queue System -- [ ] Choose queue implementation (pg-boss vs pgmq) -- [ ] Create queue tables/setup -- [ ] Implement job enqueue/dequeue -- [ ] Add retry logic with exponential backoff + jitter -- [ ] Implement DLQ (Dead Letter Queue) - -## 📦 Phase 2: Backend Workers - -### Orchestrator Worker -- [ ] State machine implementation -- [ ] Event sourcing engine -- [ ] Job scheduling and polling -- [ ] Deterministic replay logic -- [ ] Circuit breaker implementation -- [ ] Timeout handling -- [ ] Idempotency key support - -### HTTP Executor Worker -- [ ] HTTP client with retry logic -- [ ] Exponential backoff + jitter -- [ ] Timeout configuration -- [ ] Response recording for replay -- [ ] Error handling and logging - -### Node Types Implementation -- [ ] `transform` node (JMESPath) - - [ ] JMESPath parser integration - - [ ] Input/output validation - - [ ] Preview functionality -- [ ] `http_call` node - - [ ] Request configuration - - [ ] Retry policies - - [ ] Response handling -- [ ] `branch` node (conditional logic) -- [ ] `delay` node (scheduled execution) -- [ ] `terminate` node (end workflow) - -## 🎨 Phase 3: Frontend (SvelteKit) - -### Project Setup -- [ ] SvelteKit app structure -- [ ] Svelte 5 configuration -- [ ] Supabase client setup -- [ ] Authentication flow - -### Workflow Builder -- [ ] Visual DAG editor component -- [ ] Node palette (drag & drop) -- [ ] Connection/edge drawing -- [ ] Node configuration forms (JSON Schema-driven) -- [ ] Workflow validation -- [ ] Save/load workflow definitions - -### Workflow Management -- [ ] Workflow list view -- [ ] Create/edit workflow -- [ ] Version management (Draft → Publish) -- [ ] Workflow settings - -### Run Console -- [ ] Run list view -- [ ] Run detail view with DAG visualization -- [ ] Live logs via Supabase Realtime -- [ ] Event timeline -- [ ] "Resume from step" functionality -- [ ] Test run feature - -### Secrets Management -- [ ] Secrets vault UI -- [ ] Add/edit/delete secrets -- [ ] Secret masking in UI -- [ ] Project-scoped secrets - -## 🔐 Phase 4: Security - -### Authentication & Authorization -- [ ] Supabase Auth integration -- [ ] RLS policy enforcement -- [ ] Project membership management -- [ ] Role-based access control - -### Secrets Encryption -- [ ] AES-GCM encryption implementation -- [ ] KEK (Key Encryption Key) management -- [ ] Key rotation mechanism -- [ ] Secret access audit logging - -### PII & Redaction -- [ ] PII detection rules -- [ ] Automatic redaction in logs -- [ ] Artifact sanitization -- [ ] Compliance helpers - -## 🔌 Phase 5: Webhook System - -### Webhook Triggers -- [ ] Webhook endpoint creation -- [ ] Unique webhook URLs per workflow -- [ ] Signature verification (HMAC) -- [ ] JWT token support -- [ ] Payload validation -- [ ] Rate limiting (token bucket) - -### Webhook Management UI -- [ ] Webhook configuration -- [ ] Test webhook functionality -- [ ] Webhook logs -- [ ] Signature key management - -## 📊 Phase 6: Observability - -### Logging -- [ ] Structured logging implementation -- [ ] Log levels and filtering -- [ ] Realtime log streaming (Supabase) -- [ ] Log retention policies - -### Metrics -- [ ] Materialized views for metrics -- [ ] Run success/failure rates -- [ ] Execution time statistics -- [ ] Queue depth monitoring -- [ ] Error rate tracking - -### Alerting -- [ ] Failure alert system -- [ ] Webhook for alerts -- [ ] Alert configuration UI - -## 🧪 Phase 7: Testing - -### Unit Tests -- [ ] Worker logic tests -- [ ] Node execution tests -- [ ] Transform/JMESPath tests -- [ ] Retry logic tests - -### Integration Tests -- [ ] End-to-end workflow tests -- [ ] Webhook trigger tests -- [ ] Event sourcing replay tests -- [ ] Multi-tenant isolation tests - -### Performance Tests -- [ ] Load testing -- [ ] Concurrent execution tests -- [ ] Queue throughput tests - -## 📚 Phase 8: Documentation - -### User Documentation -- [ ] Getting started guide -- [ ] Workflow builder tutorial -- [ ] Node reference documentation -- [ ] JMESPath examples -- [ ] Webhook setup guide -- [ ] Secrets management guide - -### Developer Documentation -- [ ] Architecture deep-dive -- [ ] API documentation -- [ ] Database schema documentation -- [ ] Contributing guide -- [ ] Deployment guide - -## 🚀 Phase 9: Deployment & Operations - -### Production Readiness -- [ ] Health check endpoints -- [ ] Graceful shutdown -- [ ] Connection pooling optimization -- [ ] Resource limits configuration -- [ ] Backup strategy - -### Monitoring -- [ ] Application metrics -- [ ] Database performance monitoring -- [ ] Queue health monitoring -- [ ] Error tracking integration - -### CI/CD -- [ ] GitHub Actions workflows -- [ ] Automated testing -- [ ] Database migration automation -- [ ] Deployment automation - -## 🎯 Phase 10: Polish & Launch - -### Performance Optimization -- [ ] Query optimization -- [ ] Index tuning -- [ ] Caching strategy -- [ ] Bundle size optimization - -### UX Improvements -- [ ] Loading states -- [ ] Error messages -- [ ] Keyboard shortcuts -- [ ] Mobile responsiveness -- [ ] Dark mode - -### Launch Prep -- [ ] Security audit -- [ ] Performance benchmarks -- [ ] Documentation review -- [ ] Demo workflows -- [ ] Marketing site +Every open issue on [profullstack/meshhook](https://github.com/profullstack/meshhook/issues), grouped by milestone. **105 open**, 118 closed, 223 total. + +Summaries are extracted from each issue's PRD overview. Regenerate with `node scripts/generate-todo.mjs > TODO.md`. + +## Contents + +- [Phase 3: Frontend (SvelteKit)](#phase-3-frontend-sveltekit) — 24 open +- [Phase 4: Security](#phase-4-security) — 12 open +- [Phase 5: Webhook System](#phase-5-webhook-system) — 10 open +- [Phase 6: Observability](#phase-6-observability) — 12 open +- [Phase 7: Testing](#phase-7-testing) — 11 open +- [Phase 8: Documentation](#phase-8-documentation) — 11 open +- [Phase 9: Deployment & Operations](#phase-9-deployment--operations) — 12 open +- [Phase 10: Polish & Launch](#phase-10-polish--launch) — 13 open + +## Phase 3: Frontend (SvelteKit) + +- [ ] **[#116](https://github.com/profullstack/meshhook/issues/116) SvelteKit app structure** `project-setup` + As MeshHook progresses into Phase 3, focusing on Frontend development using SvelteKit, establishing a robust and scalable app structure becomes paramount. +- [ ] **[#117](https://github.com/profullstack/meshhook/issues/117) Svelte 5 configuration** `project-setup` + As part of Phase 3: Frontend (SvelteKit) in the MeshHook project development, this task focuses on configuring Svelte 5 to align with the overarching goals of the project. +- [ ] **[#118](https://github.com/profullstack/meshhook/issues/118) Supabase client setup** `project-setup` + The purpose of this task is to set up the Supabase client for the MeshHook project, under Phase 3: Frontend (SvelteKit). +- [ ] **[#119](https://github.com/profullstack/meshhook/issues/119) Authentication flow** `project-setup` + The Authentication Flow task is a crucial component of the MeshHook project's Phase 3: Frontend (SvelteKit) development. +- [ ] **[#120](https://github.com/profullstack/meshhook/issues/120) Visual DAG editor component** `workflow-builder` + The Visual Directed Acyclic Graph (DAG) Editor Component is a crucial part of the MeshHook project's Workflow Builder section. +- [ ] **[#121](https://github.com/profullstack/meshhook/issues/121) Node palette (drag & drop)** `workflow-builder` + This task focuses on enhancing the visual workflow builder within the MeshHook project by implementing a node palette that supports drag-and-drop functionality. +- [ ] **[#122](https://github.com/profullstack/meshhook/issues/122) Connection/edge drawing** `workflow-builder` + The connection/edge drawing task is a critical component of the MeshHook project's Workflow Builder, enabling users to visually define the flow between different nodes within their workflows. +- [ ] **[#123](https://github.com/profullstack/meshhook/issues/123) Node configuration forms (JSON Schema-driven)** `workflow-builder` + The objective of this task is to enhance the MeshHook workflow builder's user experience by implementing JSON Schema-driven configuration forms for workflow nodes. +- [ ] **[#124](https://github.com/profullstack/meshhook/issues/124) Workflow validation** `workflow-builder` + Workflow validation is a critical feature in the MeshHook project, ensuring that workflows created by users are syntactically and semantically correct before being saved and executed. +- [ ] **[#125](https://github.com/profullstack/meshhook/issues/125) Save/load workflow definitions** `workflow-builder` + The save/load workflow definitions feature is a critical component of the MeshHook project, enabling users to persist their workflow configurations and retrieve them for future editing or execution. +- [ ] **[#126](https://github.com/profullstack/meshhook/issues/126) Workflow list view** `workflow-management` + The Workflow List View is a critical component of the MeshHook project, aimed at enhancing the user experience by providing an efficient and intuitive interface for managing workflows. +- [ ] **[#127](https://github.com/profullstack/meshhook/issues/127) Create/edit workflow** `workflow-management` + The "Create/Edit Workflow" feature is a cornerstone of the MeshHook project, enabling users to define and modify their workflow processes visually. +- [ ] **[#128](https://github.com/profullstack/meshhook/issues/128) Version management (Draft → Publish)** `workflow-management` + ### Purpose The Version Management feature is a critical component of the MeshHook project, enabling users to transition workflow definitions from a draft state to a published version. +- [ ] **[#129](https://github.com/profullstack/meshhook/issues/129) Workflow settings** `workflow-management` + The objective of this task is to implement and enhance the workflow settings functionality within MeshHook, aligning with the application's core features and project goals. +- [ ] **[#130](https://github.com/profullstack/meshhook/issues/130) Run list view** `run-console` + The Run List View is a crucial component of the Run Console section in MeshHook, designed to provide users with a comprehensive, real-time overview of workflow runs. +- [ ] **[#131](https://github.com/profullstack/meshhook/issues/131) Run detail view with DAG visualization** `run-console` + The Run Detail View with DAG Visualization task is a critical component of the MeshHook project's Phase 3 milestone, focusing on enhancing the Run Console section. +- [ ] **[#132](https://github.com/profullstack/meshhook/issues/132) Live logs via Supabase Realtime** `run-console` + As part of Phase 3 in the MeshHook project, focusing on the Run Console section, this PRD outlines the implementation of live logs using Supabase Realtime. +- [ ] **[#133](https://github.com/profullstack/meshhook/issues/133) Event timeline** `run-console` + The Event Timeline is a critical feature in the Run Console section of MeshHook, aimed at enhancing user experience by providing a visual representation of event sequences in workflow runs. +- [ ] **[#134](https://github.com/profullstack/meshhook/issues/134) "Resume from step" functionality** `run-console` + The "Resume from step" functionality is a critical feature designed to enhance the user experience and operational resilience of the MeshHook workflow engine. +- [ ] **[#135](https://github.com/profullstack/meshhook/issues/135) Test run feature** `run-console` + The objective of the "Test Run Feature" is to enhance MeshHook's capabilities by allowing users to execute workflow tests directly from the Run Console. +- [ ] **[#136](https://github.com/profullstack/meshhook/issues/136) Secrets vault UI** `secrets-management` + The Secrets Vault UI is a critical component of the MeshHook project's Phase 3 development, focusing on Secrets Management. +- [ ] **[#137](https://github.com/profullstack/meshhook/issues/137) Add/edit/delete secrets** `secrets-management` + This PRD outlines the requirements and implementation strategy for adding, editing, and deleting secrets within the MeshHook project. +- [ ] **[#138](https://github.com/profullstack/meshhook/issues/138) Secret masking in UI** `secrets-management` + Task Objective: Implement secret masking within the MeshHook UI to enhance security and privacy by preventing the exposure of sensitive information. +- [ ] **[#139](https://github.com/profullstack/meshhook/issues/139) Project-scoped secrets** `secrets-management` + The objective of this task is to implement project-scoped secrets within the MeshHook platform. + +## Phase 4: Security + +- [ ] **[#140](https://github.com/profullstack/meshhook/issues/140) Supabase Auth integration** `authentication-authorization` + The integration of Supabase Auth into MeshHook represents a crucial milestone in Phase 4: Security, aligning with our authentication and authorization objectives. +- [ ] **[#141](https://github.com/profullstack/meshhook/issues/141) RLS policy enforcement** `authentication-authorization` + Row-Level Security (RLS) policy enforcement is a critical feature for MeshHook to ensure data isolation and security across multi-tenant environments. +- [ ] **[#142](https://github.com/profullstack/meshhook/issues/142) Project membership management** `authentication-authorization` + The Project Membership Management feature is a critical component of MeshHook's Phase 4 security enhancements. +- [ ] **[#143](https://github.com/profullstack/meshhook/issues/143) Role-based access control** `authentication-authorization` + The implementation of Role-Based Access Control (RBAC) is a critical feature for enhancing the security and flexibility of the MeshHook platform. +- [ ] **[#144](https://github.com/profullstack/meshhook/issues/144) AES-GCM encryption implementation** `secrets-encryption` + The AES-GCM encryption implementation is a critical feature for the MeshHook project, directly contributing to our goal of offering a secure, multi-tenant workflow engine. +- [ ] **[#145](https://github.com/profullstack/meshhook/issues/145) KEK (Key Encryption Key) management** `secrets-encryption` + As part of MeshHook's Phase 4: Security enhancements, the KEK (Key Encryption Key) Management System is a fundamental project initiative aimed at bolstering the security framework of MeshHook. +- [ ] **[#146](https://github.com/profullstack/meshhook/issues/146) Key rotation mechanism** `secrets-encryption` + The introduction of a key rotation mechanism represents a significant security enhancement for MeshHook, a webhook-first, deterministic, Postgres-native workflow engine. +- [ ] **[#147](https://github.com/profullstack/meshhook/issues/147) Secret access audit logging** `secrets-encryption` + The task of implementing secret access audit logging is pivotal for enhancing MeshHook's security framework, making it an indispensable feature for ensuring transparency, accountability, and compliance. +- [ ] **[#148](https://github.com/profullstack/meshhook/issues/148) PII detection rules** `pii-redaction` + The implementation of Personally Identifiable Information (PII) detection rules within MeshHook is a critical enhancement aimed at bolstering the security and privacy of the data processed through the system. +- [ ] **[#149](https://github.com/profullstack/meshhook/issues/149) Automatic redaction in logs** `pii-redaction` + ### 1.1 Purpose The purpose of this feature development is to implement an automatic redaction system within MeshHook’s logging facilities to ensure that Personally Identifiable Information (PII) is detected and redacted from logs before… +- [ ] **[#150](https://github.com/profullstack/meshhook/issues/150) Artifact sanitization** `pii-redaction` + Artifact Sanitization is a critical feature designed to enhance the security posture of MeshHook by automatically redacting Personally Identifiable Information (PII) from workflow artifacts. +- [ ] **[#151](https://github.com/profullstack/meshhook/issues/151) Compliance helpers** `pii-redaction` + In Phase 4 of MeshHook's development, the focus shifts towards enhancing the platform's security capabilities to ensure compliance with global data protection standards. + +## Phase 5: Webhook System + +- [ ] **[#152](https://github.com/profullstack/meshhook/issues/152) Webhook endpoint creation** `webhook-triggers` + This Product Requirements Document (PRD) details the implementation process for creating webhook endpoints within MeshHook, a webhook-first, Postgres-native workflow engine. +- [ ] **[#153](https://github.com/profullstack/meshhook/issues/153) Unique webhook URLs per workflow** `webhook-triggers` + The purpose of this task is to enhance the security and operational efficiency of MeshHook by implementing unique webhook URLs for each workflow. +- [ ] **[#154](https://github.com/profullstack/meshhook/issues/154) Signature verification (HMAC)** `webhook-triggers` + The implementation of HMAC (Hash-based Message Authentication Code) signature verification for MeshHook's webhook triggers is a critical enhancement aimed at bolstering the security framework of our webhook-first, deterministic, Postgres… +- [ ] **[#155](https://github.com/profullstack/meshhook/issues/155) JWT token support** `webhook-triggers` + This Product Requirements Document (PRD) details the addition of JWT (JSON Web Token) token support for webhook triggers in the MeshHook project. +- [ ] **[#156](https://github.com/profullstack/meshhook/issues/156) Payload validation** `webhook-triggers` + In the continuous evolution of MeshHook's webhook system, enhancing security and operational integrity is paramount. +- [ ] **[#157](https://github.com/profullstack/meshhook/issues/157) Rate limiting (token bucket)** `webhook-triggers` + The objective of this task is to integrate a rate limiting mechanism, specifically using the token bucket algorithm, into the MeshHook project’s webhook system. +- [ ] **[#158](https://github.com/profullstack/meshhook/issues/158) Webhook configuration** `webhook-management-ui` + The Webhook Configuration feature is a core addition to the MeshHook project, aimed at empowering users to easily set up and manage webhook triggers for their automated workflows. +- [ ] **[#159](https://github.com/profullstack/meshhook/issues/159) Test webhook functionality** `webhook-management-ui` + This PRD outlines the requirements and implementation strategy for testing the webhook functionality within MeshHook, a webhook-first, deterministic, Postgres-native workflow engine. +- [ ] **[#160](https://github.com/profullstack/meshhook/issues/160) Webhook logs** `webhook-management-ui` + The enhancement of the Webhook Logs feature is a critical component in Phase 5 of the MeshHook project, specifically within the Webhook System. +- [ ] **[#161](https://github.com/profullstack/meshhook/issues/161) Signature key management** `webhook-management-ui` + The development of a Signature Key Management System within MeshHook's Webhook Management UI is aimed at enhancing the security mechanisms of our webhook system. + +## Phase 6: Observability + +- [ ] **[#162](https://github.com/profullstack/meshhook/issues/162) Structured logging implementation** `logging` + The implementation of structured logging is a strategic enhancement to MeshHook, aimed at bolstering the system's observability and diagnostic capabilities. +- [ ] **[#163](https://github.com/profullstack/meshhook/issues/163) Log levels and filtering** `logging` + The MeshHook project aims to introduce log levels and filtering to enhance its observability features, aligning with the project's goal of providing a reliable and scalable webhook-first, deterministic workflow engine. +- [ ] **[#164](https://github.com/profullstack/meshhook/issues/164) Realtime log streaming (Supabase)** `logging` + ### Purpose The implementation of realtime log streaming using Supabase Realtime represents an essential enhancement to the MeshHook workflow engine's observability features. +- [ ] **[#165](https://github.com/profullstack/meshhook/issues/165) Log retention policies** `logging` + The introduction of log retention policies in MeshHook is pivotal for efficient log management, optimizing storage utilization, and adhering to compliance requirements. +- [ ] **[#166](https://github.com/profullstack/meshhook/issues/166) Materialized views for metrics** `metrics` + The introduction of materialized views for metrics in MeshHook aims to enhance observability and performance monitoring by aggregating and storing key workflow metrics in a more efficient and accessible manner. +- [ ] **[#167](https://github.com/profullstack/meshhook/issues/167) Run success/failure rates** `metrics` + This PRD outlines the implementation of run success and failure rates to enhance MeshHook's observability features. +- [ ] **[#168](https://github.com/profullstack/meshhook/issues/168) Execution time statistics** `metrics` + The integration of execution time statistics is a strategic enhancement aimed at bolstering MeshHook's observability capabilities. +- [ ] **[#169](https://github.com/profullstack/meshhook/issues/169) Queue depth monitoring** `metrics` + In the realm of workflow engines, monitoring the depth of job queues is paramount for maintaining system health and ensuring efficient operation. +- [ ] **[#170](https://github.com/profullstack/meshhook/issues/170) Error rate tracking** `metrics` + The purpose of this task is to integrate a sophisticated error rate tracking system into MeshHook. +- [ ] **[#171](https://github.com/profullstack/meshhook/issues/171) Failure alert system** `alerting` + The Failure Alert System is a critical addition to MeshHook's Phase 6: Observability, designed to enhance the reliability and operational visibility of the webhook-first, deterministic, Postgres-native workflow engine. +- [ ] **[#172](https://github.com/profullstack/meshhook/issues/172) Webhook for alerts** `alerting` + In Phase 6: Observability, MeshHook aims to enhance its workflow engine by introducing a webhook-based alerting mechanism. +- [ ] **[#173](https://github.com/profullstack/meshhook/issues/173) Alert configuration UI** `alerting` + The Alert Configuration UI aims to enhance MeshHook's observability capabilities by allowing users to configure alerts based on metrics or events within their workflow executions. + +## Phase 7: Testing + +- [ ] **[#174](https://github.com/profullstack/meshhook/issues/174) Worker logic tests** `unit-tests` + This PRD details the implementation and validation of unit tests for the worker logic within MeshHook, a webhook-first, deterministic, Postgres-native workflow engine. +- [ ] **[#175](https://github.com/profullstack/meshhook/issues/175) Node execution tests** `unit-tests` + The MeshHook workflow engine, designed to provide a robust, secure, and efficient platform for automating workflows, is now entering Phase 7: Testing, with a focus on ensuring the reliability and stability of its individual nodes. +- [ ] **[#176](https://github.com/profullstack/meshhook/issues/176) Transform/JMESPath tests** `unit-tests` + This document outlines the requirements and approach for enhancing the reliability and effectiveness of the Transform/JMESPath feature in MeshHook. +- [ ] **[#177](https://github.com/profullstack/meshhook/issues/177) Retry logic tests** `unit-tests` + ### Purpose This document outlines the requirements and approach for implementing and testing the retry logic within MeshHook's HTTP Executor. +- [ ] **[#178](https://github.com/profullstack/meshhook/issues/178) End-to-end workflow tests** `integration-tests` + End-to-end (E2E) testing is a critical phase in ensuring the robustness and reliability of the MeshHook workflow engine. +- [ ] **[#179](https://github.com/profullstack/meshhook/issues/179) Webhook trigger tests** `integration-tests` + As MeshHook progresses into its testing phase, the reliability and robustness of its webhook trigger mechanism are paramount. +- [ ] **[#180](https://github.com/profullstack/meshhook/issues/180) Event sourcing replay tests** `integration-tests` + In the pursuit of ensuring MeshHook's reliability and robustness, particularly in handling workflows, the implementation of event sourcing replay tests is paramount. +- [ ] **[#181](https://github.com/profullstack/meshhook/issues/181) Multi-tenant isolation tests** `integration-tests` + This PRD focuses on ensuring that MeshHook's multi-tenant architecture rigorously maintains data isolation across tenants to uphold security and privacy standards. +- [ ] **[#182](https://github.com/profullstack/meshhook/issues/182) Load testing** `performance-tests` + The objective of this Product Requirements Document (PRD) is to outline the approach for conducting comprehensive load testing on the MeshHook platform. +- [ ] **[#183](https://github.com/profullstack/meshhook/issues/183) Concurrent execution tests** `performance-tests` + In the realm of workflow engines, MeshHook stands out with its webhook-trigger capabilities, visual simplicity, and durable execution model. +- [ ] **[#184](https://github.com/profullstack/meshhook/issues/184) Queue throughput tests** `performance-tests` + This PRD addresses the requirements and methodologies for conducting queue throughput tests on MeshHook's queueing system. + +## Phase 8: Documentation + +- [ ] **[#185](https://github.com/profullstack/meshhook/issues/185) Getting started guide** `user-documentation` + This Product Requirements Document (PRD) outlines the creation of a Getting Started Guide for MeshHook, a webhook-first, deterministic, Postgres-native workflow engine. +- [ ] **[#186](https://github.com/profullstack/meshhook/issues/186) Workflow builder tutorial** `user-documentation` + The development of a Workflow Builder Tutorial is tasked with providing an engaging, interactive learning experience for users of MeshHook, enhancing their understanding and proficiency with the visual DAG builder. +- [ ] **[#187](https://github.com/profullstack/meshhook/issues/187) Node reference documentation** `user-documentation` + The MeshHook project is advancing into Phase 8, focusing on enriching user documentation. +- [ ] **[#188](https://github.com/profullstack/meshhook/issues/188) JMESPath examples** `user-documentation` + The integration of JMESPath examples into MeshHook's documentation is a strategic enhancement aimed at bolstering the platform's usability and user empowerment. +- [ ] **[#189](https://github.com/profullstack/meshhook/issues/189) Webhook setup guide** `user-documentation` + The creation of a comprehensive webhook setup guide is a strategic effort to enhance the user experience by providing clear, step-by-step instructions on integrating MeshHook's webhook functionality into their systems. +- [ ] **[#190](https://github.com/profullstack/meshhook/issues/190) Secrets management guide** `user-documentation` + This PRD outlines the development of a comprehensive secrets management guide for MeshHook. +- [ ] **[#191](https://github.com/profullstack/meshhook/issues/191) Architecture deep-dive** `developer-documentation` + The MeshHook project, a webhook-first, deterministic, Postgres-native workflow engine, aims to combine the visual simplicity of n8n with the durability of Temporal, all under a permissive MIT license. +- [ ] **[#192](https://github.com/profullstack/meshhook/issues/192) API documentation** `developer-documentation` + The purpose of this Product Requirements Document (PRD) is to outline the development of comprehensive API documentation for MeshHook. +- [ ] **[#193](https://github.com/profullstack/meshhook/issues/193) Database schema documentation** `developer-documentation` + The MeshHook project, a webhook-first, deterministic, Postgres-native workflow engine, requires comprehensive documentation of its database schema to facilitate understanding, development, integration, and maintenance by developers. +- [ ] **[#194](https://github.com/profullstack/meshhook/issues/194) Contributing guide** `developer-documentation` + The task of creating a Contributing Guide for MeshHook is aimed at enhancing the project's developer documentation to facilitate a seamless onboarding process for new contributors. +- [ ] **[#195](https://github.com/profullstack/meshhook/issues/195) Deployment guide** `developer-documentation` + The Deployment Guide for MeshHook is a crucial piece of documentation aimed at streamlining the deployment process for developers and teams looking to leverage MeshHook for building webhook-first, deterministic, Postgres-native workflow… + +## Phase 9: Deployment & Operations + +- [ ] **[#196](https://github.com/profullstack/meshhook/issues/196) Health check endpoints** `production-readiness` + The implementation of health check endpoints is a critical step towards ensuring MeshHook’s production readiness. +- [ ] **[#197](https://github.com/profullstack/meshhook/issues/197) Graceful shutdown** `production-readiness` + The implementation of a graceful shutdown mechanism for MeshHook is a strategic enhancement aimed at bolstering the platform's reliability, performance, and data integrity during the shutdown processes. +- [ ] **[#198](https://github.com/profullstack/meshhook/issues/198) Connection pooling optimization** `production-readiness` + Optimizing connection pooling is a critical enhancement for MeshHook, a webhook-first, deterministic, Postgres-native workflow engine designed for high scalability and reliability. +- [ ] **[#199](https://github.com/profullstack/meshhook/issues/199) Resource limits configuration** `production-readiness` + The implementation of configurable resource limits within MeshHook, as outlined in Issue #199, is a critical feature aimed at enhancing the platform's operational efficiency, reliability, and cost-effectiveness in production environments. +- [ ] **[#200](https://github.com/profullstack/meshhook/issues/200) Backup strategy** `production-readiness` + The integration of a comprehensive backup strategy is critical as MeshHook moves toward production readiness. +- [ ] **[#201](https://github.com/profullstack/meshhook/issues/201) Application metrics** `monitoring` + Application metrics integration into MeshHook aims to enhance the platform’s observability, performance monitoring, and operational efficiency by embedding comprehensive metrics collection and reporting capabilities. +- [ ] **[#202](https://github.com/profullstack/meshhook/issues/202) Database performance monitoring** `monitoring` + The objective of integrating database performance monitoring into MeshHook's architecture is to ensure optimal performance, reliability, and scalability of the PostgreSQL database underlying our webhook-first, deterministic, Postgres-nat… +- [ ] **[#203](https://github.com/profullstack/meshhook/issues/203) Queue health monitoring** `monitoring` + The Queue Health Monitoring feature is an essential addition to MeshHook, a webhook-first, deterministic, Postgres-native workflow engine. +- [ ] **[#204](https://github.com/profullstack/meshhook/issues/204) Error tracking integration** `monitoring` + As MeshHook evolves, ensuring the stability and reliability of both its frontend and backend components becomes increasingly crucial. +- [ ] **[#206](https://github.com/profullstack/meshhook/issues/206) Automated testing** `cicd` + The MeshHook project, a webhook-first, deterministic, Postgres-native workflow engine, requires a robust automated testing framework to ensure high-quality and reliable software delivery. +- [ ] **[#207](https://github.com/profullstack/meshhook/issues/207) Database migration automation** `cicd` + ### Purpose The purpose of the Database Migration Automation task is to streamline the process of applying schema changes across different environments of the MeshHook project. +- [ ] **[#208](https://github.com/profullstack/meshhook/issues/208) Deployment automation** `cicd` + The MeshHook Deployment Automation project is a critical initiative aimed at enhancing the efficiency, reliability, and security of deploying the MeshHook workflow engine across various environments. + +## Phase 10: Polish & Launch + +- [ ] **[#209](https://github.com/profullstack/meshhook/issues/209) Query optimization** `performance-optimization` + The MeshHook project, a webhook-first, deterministic, Postgres-native workflow engine, is entering Phase 10: Polish & Launch, with an emphasis on optimizing database queries to enhance the overall performance and efficiency of the system. +- [ ] **[#210](https://github.com/profullstack/meshhook/issues/210) Index tuning** `performance-optimization` + The objective of this Product Requirements Document (PRD) is to provide a comprehensive plan for optimizing the Postgres database indexes used by MeshHook, a webhook-first, deterministic, Postgres-native workflow engine. +- [ ] **[#211](https://github.com/profullstack/meshhook/issues/211) Caching strategy** `performance-optimization` + The caching strategy for MeshHook is a crucial enhancement aimed at bolstering the platform's performance by minimizing direct database interactions, thereby reducing load and optimizing response times for a seamless user experience. +- [ ] **[#212](https://github.com/profullstack/meshhook/issues/212) Bundle size optimization** `performance-optimization` + This document outlines the plan for optimizing the bundle size of MeshHook, an essential step within the project's Phase 10: Polish & Launch. +- [ ] **[#214](https://github.com/profullstack/meshhook/issues/214) Error messages** `ux-improvements` + The objective of enhancing error messages within MeshHook is to significantly improve the clarity, specificity, and actionability of error messages across the platform. +- [ ] **[#215](https://github.com/profullstack/meshhook/issues/215) Keyboard shortcuts** `ux-improvements` + This document outlines the plan for implementing keyboard shortcuts within the MeshHook platform. +- [ ] **[#216](https://github.com/profullstack/meshhook/issues/216) Mobile responsiveness** `ux-improvements` + The MeshHook project is on the brink of launching its innovative workflow engine, designed to combine the visual simplicity of n8n with the durability of Temporal, all within a Postgres-native structure that supports multi-tenant securit… +- [ ] **[#217](https://github.com/profullstack/meshhook/issues/217) Dark mode** `ux-improvements` + The implementation of Dark Mode in MeshHook is aimed at enhancing the user experience by providing a visually comfortable alternative for users, especially in low-light environments. +- [ ] **[#218](https://github.com/profullstack/meshhook/issues/218) Security audit** `launch-prep` + The purpose of this PRD is to guide the comprehensive security audit of MeshHook, a webhook-first, deterministic, Postgres-native workflow engine. +- [ ] **[#219](https://github.com/profullstack/meshhook/issues/219) Performance benchmarks** `launch-prep` + ### Objective The primary objective is to establish a comprehensive performance benchmarking system for MeshHook that ensures its components meet and exceed the required performance standards for webhook processing, workflow execution, a… +- [ ] **[#220](https://github.com/profullstack/meshhook/issues/220) Documentation review** `launch-prep` + The Documentation Review initiative for MeshHook, under Task #220, focuses on a holistic update and refinement of the project's documentation. +- [ ] **[#221](https://github.com/profullstack/meshhook/issues/221) Demo workflows** `launch-prep` + With MeshHook approaching its launch phase, the introduction of demo workflows serves a strategic role in highlighting the platform's capabilities and easing the onboarding process for new users. +- [ ] **[#222](https://github.com/profullstack/meshhook/issues/222) Marketing site** `launch-prep` + The launch of the MeshHook marketing site is a critical component of the Phase 10 milestone: Polish & Launch. --- -## Current Sprint Focus - -**Environment Setup** ✅ COMPLETE -- All environment configuration completed -- Supabase CLI integrated -- Documentation updated - -**Next Up: Phase 1 - Core Infrastructure** -- Start with database schema design -- Implement core tables with RLS -- Set up queue system (pg-boss recommended) - ---- - -## Notes - -- Follow KISS principle - keep it simple -- Use Supabase CLI for all migrations (`pnpx supabase migration new`) -- All tests go in `./tests` directory -- Port 8080 for all services -- Event sourcing is key for determinism and replay -- JMESPath for transforms (no arbitrary code execution in v1) \ No newline at end of file +Generated from the GitHub issue tracker. 105 open issues across 8 milestones. diff --git a/apps/web/package.json b/apps/web/package.json index 2f2c288..d638e3a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,9 +12,8 @@ "format": "prettier --write ." }, "dependencies": { + "@libsql/client": "^0.15.7", "@meshhook/shared": "workspace:*", - "@supabase/ssr": "^0.5.2", - "@supabase/supabase-js": "^2.45.1", "@xyflow/svelte": "^1.3.1", "jmespath": "^0.16.0", "undici": "^6.19.8" @@ -38,4 +37,4 @@ "vite": "^5.4.11", "vitest": "^3.2.4" } -} \ No newline at end of file +} diff --git a/apps/web/src/hooks.server.js b/apps/web/src/hooks.server.js index b3727b4..646cc6e 100644 --- a/apps/web/src/hooks.server.js +++ b/apps/web/src/hooks.server.js @@ -1,61 +1,52 @@ /** * SvelteKit Server Hooks - * Handles server-side middleware including authentication and www to non-www redirects + * + * Resolves the session cookie to a user on every request, and handles the + * www -> non-www redirect. + * + * Previously this created a Supabase client per request and called + * supabase.auth.getUser(). Sessions are now MeshHook's own rows in Turso, so + * the cookie is looked up directly. event.locals.supabase is gone — routes take + * event.locals.user and query the database through @meshhook/shared. */ -import { createServerSupabaseClient } from '$lib/supabase.js'; +import { validateSession, SESSION_COOKIE, sessionCookieOptions } from '@meshhook/shared/lib/auth.js'; +import { dev } from '$app/environment'; /** - * Handle function runs on every server request * @param {Object} params - * @param {Request} params.event - The request event - * @param {Function} params.resolve - Function to resolve the request + * @param {import('@sveltejs/kit').RequestEvent} params.event + * @param {Function} params.resolve * @returns {Promise} */ export async function handle({ event, resolve }) { - // Get the host from the request headers const host = event.request.headers.get('host'); - // Check if the host starts with 'www.' if (host?.startsWith('www.')) { - // Extract the non-www domain - const nonWwwHost = host.slice(4); // Remove 'www.' prefix - - // Get the full URL + const nonWwwHost = host.slice(4); const url = new URL(event.request.url); - - // Construct the redirect URL with the non-www host const redirectUrl = `${url.protocol}//${nonWwwHost}${url.pathname}${url.search}${url.hash}`; - // Return a 301 permanent redirect return new Response(null, { status: 301, - headers: { - location: redirectUrl - } + headers: { location: redirectUrl } }); } - // Create Supabase client for this request - const supabase = createServerSupabaseClient(event); - - // Get the session using getUser() for security (not getSession()) - const { - data: { user }, - error - } = await supabase.auth.getUser(); - - // Make user available to all routes via event.locals - event.locals.supabase = supabase; - event.locals.user = user; - event.locals.getSession = async () => { - const { - data: { session } - } = await supabase.auth.getSession(); - return session; - }; - - // Continue with normal request handling - const response = await resolve(event); - return response; -} \ No newline at end of file + const token = event.cookies.get(SESSION_COOKIE); + const result = token ? await validateSession(token) : null; + + if (token && !result) { + // Expired or revoked: clear it so the browser stops sending it. + event.cookies.delete(SESSION_COOKIE, { path: '/' }); + } else if (result) { + // validateSession slides the expiry forward; mirror that onto the cookie + // so an active session is not logged out by the cookie expiring first. + event.cookies.set(SESSION_COOKIE, token, sessionCookieOptions({ secure: !dev })); + } + + event.locals.user = result?.user ?? null; + event.locals.session = result?.session ?? null; + + return resolve(event); +} diff --git a/apps/web/src/lib/auth.js b/apps/web/src/lib/auth.js index 10c64cb..62d2c2a 100644 --- a/apps/web/src/lib/auth.js +++ b/apps/web/src/lib/auth.js @@ -1,61 +1,59 @@ /** - * Authentication utilities for server-side route protection - * Provides helpers for checking authentication and redirecting unauthorized users + * Authentication utilities for server-side route protection. + * + * getSupabase() is gone along with Supabase itself. Routes that need data now + * import the query helpers from @meshhook/shared/lib/authz.js, which apply the + * per-user scoping that RLS used to enforce in the database. */ import { redirect } from '@sveltejs/kit'; /** - * Require authentication for a route - * Redirects to login if user is not authenticated - * @param {object} event - SvelteKit event object - * @returns {object} user object if authenticated - * @throws {redirect} Redirects to /auth/login if not authenticated + * Require authentication for a page route. + * @param {import('@sveltejs/kit').RequestEvent} event + * @returns {object} the authenticated user + * @throws {redirect} to /auth/login when not authenticated */ export function requireAuth(event) { const user = event.locals.user; if (!user) { - throw redirect(303, '/auth/login'); + // Preserve where the user was headed so login can send them back. + const next = encodeURIComponent(event.url.pathname + event.url.search); + throw redirect(303, `/auth/login?next=${next}`); } return user; } /** - * Get authenticated user from event.locals - * Returns null if not authenticated (does not redirect) - * @param {object} event - SvelteKit event object - * @returns {object|null} user object or null + * Get the authenticated user, or null. + * @param {import('@sveltejs/kit').RequestEvent} event + * @returns {object|null} */ export function getUser(event) { return event.locals.user ?? null; } /** - * Check if user is authenticated - * @param {object} event - SvelteKit event object - * @returns {boolean} true if authenticated + * @param {import('@sveltejs/kit').RequestEvent} event + * @returns {boolean} */ export function isAuthenticated(event) { return !!event.locals.user; } /** - * Get Supabase client from event.locals - * @param {object} event - SvelteKit event object - * @returns {object} Supabase client - */ -export function getSupabase(event) { - return event.locals.supabase; -} - -/** - * Require authentication for API routes - * Returns 401 Unauthorized if user is not authenticated - * @param {object} event - SvelteKit event object - * @returns {object} user object if authenticated - * @throws {Response} Returns 401 if not authenticated + * Require authentication for an API route. + * + * Returns the user when authenticated, or a 401 Response otherwise. Callers + * must check with `instanceof Response` before using the result: + * + * const user = requireApiAuth(event); + * if (user instanceof Response) return user; + * + * @param {import('@sveltejs/kit').RequestEvent} event + * @returns {object|Response} */ export function requireApiAuth(event) { const user = event.locals.user; @@ -68,30 +66,10 @@ export function requireApiAuth(event) { }), { status: 401, - headers: { - 'Content-Type': 'application/json' - } + headers: { 'Content-Type': 'application/json' } } ); } return user; } - -/** - * Verify user has access to a project - * @param {object} supabase - Supabase client - * @param {string} userId - User ID - * @param {string} projectId - Project ID - * @returns {Promise} true if user has access - */ -export async function verifyProjectAccess(supabase, userId, projectId) { - const { data, error } = await supabase - .from('projects') - .select('id') - .eq('id', projectId) - .eq('owner', userId) - .single(); - - return !error && !!data; -} \ No newline at end of file diff --git a/apps/web/src/lib/components/LiveLogs.svelte b/apps/web/src/lib/components/LiveLogs.svelte index 7e14a49..47702f9 100644 --- a/apps/web/src/lib/components/LiveLogs.svelte +++ b/apps/web/src/lib/components/LiveLogs.svelte @@ -1,11 +1,18 @@ @@ -69,17 +39,20 @@

Sign In to MeshHook

- {#if error} -
{error}
+ {#if form?.error} +
{form.error}
{/if} -
{ e.preventDefault(); handleLogin(); }}> + + +
Password
@@ -102,7 +76,12 @@ -
diff --git a/apps/web/src/routes/auth/logout/+server.js b/apps/web/src/routes/auth/logout/+server.js index 06cf922..8c67c72 100644 --- a/apps/web/src/routes/auth/logout/+server.js +++ b/apps/web/src/routes/auth/logout/+server.js @@ -1,22 +1,25 @@ -import { createServerSupabaseClient } from '$lib/supabase.js'; +/** + * POST /auth/logout - Sign out the current user. + * + * Deleting the session row revokes it server-side immediately, which the old + * Supabase JWTs could not do — they stayed valid until they expired. + */ + import { redirect } from '@sveltejs/kit'; +import { destroySession, SESSION_COOKIE } from '@meshhook/shared/lib/auth.js'; /** - * POST /auth/logout - Sign out the current user * @param {import('@sveltejs/kit').RequestEvent} event */ export async function POST(event) { - const supabase = createServerSupabaseClient(event); + const token = event.cookies.get(SESSION_COOKIE); - const { error } = await supabase.auth.signOut(); - - if (error) { - console.error('Error signing out:', error); - return new Response(JSON.stringify({ error: error.message }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); + if (token) { + await destroySession(token); } + // Clear the cookie even if there was no session, so a stale one cannot linger. + event.cookies.delete(SESSION_COOKIE, { path: '/' }); + throw redirect(303, '/auth/login'); -} \ No newline at end of file +} diff --git a/apps/web/src/routes/runs/+page.server.js b/apps/web/src/routes/runs/+page.server.js index bbe3c30..cb39e76 100644 --- a/apps/web/src/routes/runs/+page.server.js +++ b/apps/web/src/routes/runs/+page.server.js @@ -1,30 +1,21 @@ -import { requireAuth, getSupabase } from '$lib/auth.js'; - /** - * Load runs for the list view + * Load runs for the list view. + * + * The Supabase version relied on RLS to limit rows to the caller's projects; + * listRuns() applies that scoping in the query instead. */ + +import { requireAuth } from '$lib/auth.js'; +import { listRuns } from '@meshhook/shared/lib/authz.js'; + export async function load(event) { - // Require authentication - will redirect to /login if not authenticated const user = requireAuth(event); - const supabase = getSupabase(event); try { - const { data: runs, error } = await supabase - .from('workflow_runs') - .select('*, workflow:workflow_definitions(slug)') - .order('created_at', { ascending: false }); - - if (error) { - console.error('Error loading runs:', error); - return { runs: [], error: error.message }; - } - - return { - runs: runs || [], - user - }; + const runs = await listRuns(user.id, { limit: 100 }); + return { runs, user }; } catch (error) { - console.error('Error in runs load:', error); - return { runs: [], error: error.message }; + console.error('Error loading runs:', error); + return { runs: [], user, error: 'Failed to load runs' }; } -} \ No newline at end of file +} diff --git a/apps/web/src/routes/runs/[id]/+page.server.js b/apps/web/src/routes/runs/[id]/+page.server.js index 4f590c4..a48b6eb 100644 --- a/apps/web/src/routes/runs/[id]/+page.server.js +++ b/apps/web/src/routes/runs/[id]/+page.server.js @@ -1,39 +1,50 @@ -import { createServerSupabaseClient } from '$lib/supabase.js'; -import { error } from '@sveltejs/kit'; - /** - * Load run details with workflow and events + * Load a single run with its workflow and event history. + * + * The old query selected from `runs` and `events` — neither table exists; the + * names are workflow_runs and workflow_events. It also leaned on RLS for + * tenancy, so an unauthorised id returned PGRST116 and was reported as 404. + * getRun() scopes by owner and returns null for both cases, which keeps the + * same 404 behaviour without disclosing that the run exists. */ + +import { error } from '@sveltejs/kit'; +import { requireAuth } from '$lib/auth.js'; +import { getRun, getWorkflow, listRunEvents } from '@meshhook/shared/lib/authz.js'; +import { json as parseJson } from '@meshhook/shared/lib/db.js'; + export async function load(event) { - const supabase = createServerSupabaseClient(event); + const user = requireAuth(event); const { id } = event.params; - try { - const { - data: { session } - } = await supabase.auth.getSession(); + let run; + let workflow; + let events; - if (!session) { - throw error(401, 'Unauthorized'); - } - - // Load run with workflow and events - const { data: run, error: runError } = await supabase - .from('runs') - .select('*, workflow:workflows(*), events(*)') - .eq('id', id) - .single(); + try { + run = await getRun(user.id, id); - if (runError) { - if (runError.code === 'PGRST116') { - throw error(404, 'Run not found'); - } - throw runError; + if (!run) { + throw error(404, 'Run not found'); } - return { run }; + workflow = await getWorkflow(user.id, run.workflow_id); + events = await listRunEvents(user.id, id, { limit: 1000 }); } catch (err) { + // A SvelteKit error carries a status; rethrow it untouched. + if (err?.status) throw err; console.error('Error loading run:', err); - throw error(err.status || 500, err.message || 'Failed to load run'); + throw error(500, 'Failed to load run'); } -} \ No newline at end of file + + return { + run: { + ...run, + workflow: workflow + ? { ...workflow, definition: parseJson(workflow.definition, {}) } + : null, + // payload is TEXT under SQLite, so decode it for the view. + events: events.map((e) => ({ ...e, payload: parseJson(e.payload, {}) })) + } + }; +} diff --git a/apps/web/src/routes/secrets/+page.server.js b/apps/web/src/routes/secrets/+page.server.js index 0be89d0..9f8a43a 100644 --- a/apps/web/src/routes/secrets/+page.server.js +++ b/apps/web/src/routes/secrets/+page.server.js @@ -1,41 +1,34 @@ -import { requireAuth, getSupabase } from '$lib/auth.js'; - /** - * Load secrets and projects for the secrets vault + * Load the secrets vault. + * + * Ciphertext is deliberately not selected — the list view only needs the key + * names, and the encrypted value has no business being sent to the browser. */ + +import { requireAuth } from '$lib/auth.js'; +import { db } from '@meshhook/shared/lib/db.js'; +import { listProjects, ownedProjectIdsSql } from '@meshhook/shared/lib/authz.js'; + export async function load(event) { - // Require authentication - will redirect to /login if not authenticated const user = requireAuth(event); - const supabase = getSupabase(event); try { - // Load secrets with project info - const { data: secrets, error: secretsError } = await supabase - .from('secrets') - .select('*, project:projects(name)') - .order('created_at', { ascending: false }); - - if (secretsError) { - console.error('Error loading secrets:', secretsError); - } - - // Load projects for filtering - const { data: projects, error: projectsError } = await supabase - .from('projects') - .select('id, name') - .order('name'); - - if (projectsError) { - console.error('Error loading projects:', projectsError); - } + const [secrets, projects] = await Promise.all([ + db.manyOrNone( + `select s.id, s.key, s.project_id, s.created_at, s.updated_at, + p.name as project_name + from secrets s + join projects p on p.id = s.project_id + where s.project_id in (${ownedProjectIdsSql()}) + order by s.created_at desc`, + [user.id] + ), + listProjects(user.id) + ]); - return { - secrets: secrets || [], - projects: projects || [], - user - }; + return { secrets, projects, user }; } catch (error) { console.error('Error in secrets load:', error); - return { secrets: [], projects: [], error: error.message }; + return { secrets: [], projects: [], user, error: 'Failed to load secrets' }; } -} \ No newline at end of file +} diff --git a/apps/web/src/routes/workflows/+page.server.js b/apps/web/src/routes/workflows/+page.server.js index 504a5b7..e18fe9d 100644 --- a/apps/web/src/routes/workflows/+page.server.js +++ b/apps/web/src/routes/workflows/+page.server.js @@ -1,51 +1,28 @@ -import { requireAuth, getSupabase } from '$lib/auth.js'; - /** - * Load workflows for the list view + * Load workflows for the list view. + * + * The Supabase version fetched the user's project ids, then queried workflows + * with an `in` filter, relying on RLS as a second layer. listWorkflows() does + * both in one statement — there is no second layer any more, so the scoping + * predicate has to be part of the query. */ + +import { requireAuth } from '$lib/auth.js'; +import { listWorkflows } from '@meshhook/shared/lib/authz.js'; +import { json as parseJson } from '@meshhook/shared/lib/db.js'; + export async function load(event) { - // Require authentication - will redirect to /login if not authenticated const user = requireAuth(event); - const supabase = getSupabase(event); try { - // Get user's projects first - const { data: projects, error: projectsError } = await supabase - .from('projects') - .select('id') - .eq('owner', user.id); - - if (projectsError) { - console.error('Error loading projects:', projectsError); - return { workflows: [], error: projectsError.message }; - } - - // If user has no projects, return empty list - if (!projects || projects.length === 0) { - return { workflows: [], user }; - } - - const projectIds = projects.map((p) => p.id); - - // Fetch workflows only from user's projects - // RLS policies will also enforce this, but we add explicit filter for defense-in-depth - const { data: workflows, error } = await supabase - .from('workflows') - .select('*') - .in('project_id', projectIds) - .order('updated_at', { ascending: false }); - - if (error) { - console.error('Error loading workflows:', error); - return { workflows: [], error: error.message }; - } + const workflows = await listWorkflows(user.id, { limit: 200 }); return { - workflows: workflows || [], + workflows: workflows.map((w) => ({ ...w, definition: parseJson(w.definition, {}) })), user }; } catch (error) { - console.error('Error in workflows load:', error); - return { workflows: [], error: error.message }; + console.error('Error loading workflows:', error); + return { workflows: [], user, error: 'Failed to load workflows' }; } -} \ No newline at end of file +} diff --git a/apps/web/src/routes/workflows/[id]/edit/+page.server.js b/apps/web/src/routes/workflows/[id]/edit/+page.server.js index fe86e7c..5b1e4fe 100644 --- a/apps/web/src/routes/workflows/[id]/edit/+page.server.js +++ b/apps/web/src/routes/workflows/[id]/edit/+page.server.js @@ -1,48 +1,38 @@ -import { requireAuth, getSupabase } from '$lib/auth.js'; -import { error } from '@sveltejs/kit'; - /** - * Load workflow for editing - * Requires authentication and verifies user has access to the workflow + * Load a workflow for editing. + * + * The Supabase version joined projects to compare `project.owner` against the + * user in JS. getWorkflow() puts that ownership test in the query, so a + * workflow belonging to someone else is simply not returned. + * + * It also reports 404 rather than the old 403: answering "forbidden" confirms + * the id exists, which is a small information leak on a guessable identifier. */ + +import { error } from '@sveltejs/kit'; +import { requireAuth } from '$lib/auth.js'; +import { getWorkflow } from '@meshhook/shared/lib/authz.js'; +import { json as parseJson } from '@meshhook/shared/lib/db.js'; + export async function load(event) { - // Require authentication - will redirect to /auth/login if not authenticated const user = requireAuth(event); - const supabase = getSupabase(event); const { id } = event.params; + let workflow; try { - // Fetch workflow with project information to verify access - const { data: workflow, error: fetchError } = await supabase - .from('workflows') - .select('*, project:projects!inner(id, owner)') - .eq('id', id) - .single(); - - if (fetchError) { - if (fetchError.code === 'PGRST116') { - throw error(404, 'Workflow not found'); - } - console.error('Error fetching workflow:', fetchError); - throw error(500, 'Failed to load workflow'); - } - - // Verify user has access to this workflow through project ownership - // RLS policies should handle this, but we double-check for security - if (!workflow || workflow.project?.owner !== user.id) { - throw error(403, 'You do not have permission to access this workflow'); - } - - return { - workflow, - user - }; + workflow = await getWorkflow(user.id, id); } catch (err) { - // Re-throw SvelteKit errors (like redirects and error responses) - if (err?.status) { - throw err; - } console.error('Error loading workflow:', err); throw error(500, 'Failed to load workflow'); } -} \ No newline at end of file + + if (!workflow) { + throw error(404, 'Workflow not found'); + } + + return { + // definition is TEXT under SQLite; the builder expects an object. + workflow: { ...workflow, definition: parseJson(workflow.definition, { nodes: [], edges: [] }) }, + user + }; +} diff --git a/apps/web/vite.config.js b/apps/web/vite.config.js index 3e2f45b..0b0a783 100644 --- a/apps/web/vite.config.js +++ b/apps/web/vite.config.js @@ -1,15 +1,30 @@ import { sveltekit } from '@sveltejs/kit/vite'; +/** + * @libsql/client loads a platform-specific native binding (e.g. + * @libsql/linux-x64-gnu) with a dynamic require. Rollup cannot follow that, so + * bundling it fails the build with "Could not dynamically require". Marking it + * external leaves the import in place for Node to resolve from node_modules at + * runtime, which is what adapter-node expects anyway. + * + * Keep `libsql` alongside it — that is the package holding the bindings. + */ +const NATIVE_DEPS = ['@libsql/client', 'libsql']; + export default { plugins: [sveltekit()], ssr: { // Mark worker modules as external so they're not bundled // They'll be resolved at runtime from the monorepo root - noExternal: [] + noExternal: [], + external: NATIVE_DEPS + }, + optimizeDeps: { + exclude: NATIVE_DEPS }, build: { rollupOptions: { - external: [/^\.\.\/\.\.\/workers\//] + external: [/^\.\.\/\.\.\/workers\//, ...NATIVE_DEPS, /^@libsql\//] } } -}; \ No newline at end of file +}; diff --git a/docs/Turso-Migration.md b/docs/Turso-Migration.md new file mode 100644 index 0000000..55b3e62 --- /dev/null +++ b/docs/Turso-Migration.md @@ -0,0 +1,216 @@ +# Migrating from Supabase to Turso + +MeshHook originally stored everything in Supabase: Postgres for data, RLS for +tenant isolation, pgmq for the job queue, Supabase Auth for users and sessions, +and Supabase Realtime for the live log view. It now runs on +[Turso](https://turso.tech) (libSQL/SQLite). + +SQLite is not a smaller Postgres — several things Supabase provided as platform +features had to be rebuilt. This is what changed and why. + +## At a glance + +| Concern | Before | After | +|---|---|---| +| Driver | `pg.Pool` | `@libsql/client` | +| Placeholders | `$1, $2` | `?` (translated automatically) | +| Migrations | `supabase db push` | `node scripts/db-migrate.js` | +| Tenant isolation | Row Level Security | `packages/shared/lib/authz.js` | +| Auth | Supabase Auth (JWT) | `packages/shared/lib/auth.js` (sessions) | +| Job queue | pgmq extension | `packages/shared/lib/queue.js` | +| Live logs | Supabase Realtime | Server-Sent Events | +| Secret encryption | *(claimed, never implemented)* | AES-256-GCM in `crypto.js` | +| Event storage | Monthly partitions | Single table + indexes | + +## Type mapping + +SQLite has a much smaller type system. The migrations in `migrations/` use: + +| Postgres | SQLite | Notes | +|---|---|---| +| `uuid` | `text` | Default generates a v4 via `randomblob()` | +| `jsonb` | `text` | Read with the `json()` helper from `db.js` | +| `timestamptz` | `text` | ISO-8601 UTC, e.g. `2026-08-11T12:00:00.000Z` | +| `bytea` | `blob` | | +| `bigserial` | `integer primary key` | Rowid alias, autoincrements | +| `inet` | `text` | | +| `boolean` | `integer` | `0`/`1`, with a check constraint | + +Anything that read a `jsonb` column now gets a string. `db.json(value)` decodes +it and passes objects through untouched, so it is safe to apply everywhere. + +## Row Level Security is gone + +This is the most important change to understand. + +Under Supabase, every table carried RLS policies keyed on `auth.uid()`. A query +that forgot its `where owner = ...` clause still returned only the caller's +rows — the database was the backstop. + +**SQLite has no RLS, and Turso has no concept of the calling user.** An +unfiltered query now returns every tenant's data. + +The policies were replaced by query helpers in `packages/shared/lib/authz.js`, +which apply the same rule the policies did: + +``` +projects owner = current user +secrets project_id ∈ the user's projects +workflow_definitions project_id ∈ the user's projects +workflow_runs project_id ∈ the user's projects +workflow_events run_id → workflow_runs → the user's projects +audit_log project_id ∈ the user's projects +``` + +Route handlers should use those helpers. Where a bespoke query is unavoidable, +embed `ownedProjectIdsSql()` so the predicate stays in one place: + +```js +const rows = await db.manyOrNone( + `select * from secrets where project_id in (${ownedProjectIdsSql()})`, + [user.id], +); +``` + +Cross-tenant access is covered by tests in `packages/shared/lib/auth.test.js`. + +## Authentication + +Supabase Auth handled signup, login, JWT issuance and cookie refresh. MeshHook +now owns all of it: + +- Passwords use **scrypt** (`node:crypto`, N=16384) — no new dependency. +- Sessions are opaque 256-bit tokens. Only the **SHA-256 of the token** is + stored, so a database leak cannot be replayed as a login. +- Sessions are rows, not JWTs, so **logout revokes immediately**. The old access + tokens stayed valid until they expired. +- Login and signup are SvelteKit **form actions**; credentials never pass + through client-side JavaScript. + +Sessions last 30 days and slide forward when used. + +**Not carried over:** Supabase sent a confirmation email on signup. There is no +mail provider here, so accounts are usable immediately and `users.email_verified` +stays `0`. Email verification is tracked as issue #47. + +## Job queue + +pgmq is a Postgres extension with no SQLite equivalent, and the old code reached +it through Supabase RPC (`client.rpc('pgmq_send', …)`), so transport and +implementation both had to be replaced. + +The visibility-timeout model is preserved exactly: a message is available when +`vt <= now`, reading pushes `vt` forward and increments `read_ct`, and an +unacknowledged message becomes visible again when its lease lapses. pgmq's +per-queue table pairs (`q_` / `a_`) became a single `queue_messages` +table with a `queue_name` column, since SQLite has no schemas. + +### Concurrency: the write lock + +pgmq relied on `FOR UPDATE SKIP LOCKED` for atomic claims. SQLite has no row +locks, and `@libsql/client` multiplexes every statement over **one** connection. +Two overlapping write transactions therefore interleave on that connection: the +loser gets `SQLITE_BUSY` on `BEGIN`, and the winner then fails its `COMMIT` with +*"cannot commit transaction - SQL statements in progress"*. + +Retrying alone **livelocks** — each retry recreates the interleaving that breaks +the in-flight commit. + +`db.js` therefore serialises all top-level statements and transactions through an +in-process write lock, and retries with backoff on top for cross-process +contention. SQLite permits one writer at a time regardless, so this costs no real +concurrency. The `concurrent consumers` test in `src/queue/integration.test.js` +covers it. + +## Live logs + +Supabase Realtime replicated Postgres INSERTs over a websocket. The replacement +is an SSE endpoint at `/api/runs/[id]/events` that polls `workflow_events` for +rows past the last id it sent. + +Events are append-only with a monotonic integer id, so "everything after N" is an +index lookup, and SSE reconnects on its own with `Last-Event-ID` — a client that +drops out resumes exactly where it left off. The stream closes once the run +reaches a terminal state. + +## Event partitioning + +`workflow_events` was range-partitioned by month, with a job creating future +partitions and dropping old ones. SQLite has no declarative partitioning. The +equivalent win comes from the `(run_id, ts)` index, which is what every replay +query uses. Retention is a delete by age rather than a partition drop. + +## Bugs found during the migration + +Several pre-existing faults surfaced while porting, and were fixed: + +- **Secrets were stored in plaintext.** `POST /api/secrets` inserted the raw + value with a comment claiming "encryption handled by database trigger". No such + trigger existed in any migration. Values are now encrypted with AES-256-GCM + before they reach the database. +- **`POST /api/secrets` could never have worked** — it wrote columns (`name`, + `encrypted_value`, `description`) that the table does not have. +- **`/api/workflows/[id]/versions` queried a `workflow_versions` table** that no + migration ever created. Versioning actually lives in `workflow_definitions` + rows sharing `(project_id, slug)`. +- **Publishing was not atomic.** Inserting the new version and archiving the + draft were separate requests, so a failure between them left a workflow both + published and un-archived. They now share a transaction. +- **The webhook route imported `enqueueRun`** from a module that never exported + it, and enqueued onto an in-process EventEmitter that a separately deployed + orchestrator could not observe. Jobs now go onto the real queue. +- **Webhook signature comparison used `!==`**, leaking the position of the first + differing byte through timing. It is now constant-time. +- **`/api/runs` and `runs/[id]` queried `runs`, `logs` and `events` tables** that + do not exist; the real tables are `workflow_runs` and `workflow_events`. +- **The root `.env` was never read** — `db.js` resolved the repo root one + directory short, landing in `packages/`. + +## Operational notes + +- **`:memory:` does not work** with `@libsql/client`: each connection gets its + own empty database, so schema created by one statement is invisible to the + next. Tests use a temp file instead; `createDb` rejects `:memory:` outright. +- **Foreign keys** are per-connection in SQLite. `scripts/verify-migration.js` + reports the pragma state, since cascade deletes silently do nothing when off. +- **`@libsql/client` is marked external** in `apps/web/vite.config.js` — it loads + a platform-specific native binding via dynamic `require`, which Rollup cannot + bundle. +- **Railway builds need `packageManager` in `package.json` and a committed + `pnpm-lock.yaml`.** Without them Railpack falls back to `npm install`, which + cannot resolve pnpm's `workspace:*` protocol. `pnpm-lock.yaml` was previously + gitignored, which is what broke the build. + +## Environment variables + +Replaced: + +``` +DATABASE_URL=postgresql://... -> TURSO_DATABASE_URL=libsql://... +SUPABASE_URL=... TURSO_AUTH_TOKEN=... +SUPABASE_ANON_KEY=... +SUPABASE_SERVICE_ROLE_KEY=... +PUBLIC_SUPABASE_URL=... +PUBLIC_SUPABASE_ANON_KEY=... +``` + +Added: + +``` +SECRETS_ENCRYPTION_KEY= # openssl rand -hex 32 +``` + +`DATABASE_URL` is still accepted as an alias for `TURSO_DATABASE_URL`, but a +`postgres://` value is rejected with an explicit error rather than failing later +with an opaque protocol mismatch. + +Losing `SECRETS_ENCRYPTION_KEY` makes every stored secret unrecoverable. Back it +up; `scripts/setup.js` preserves an existing key when re-run. + +## Data migration + +These instructions cover schema and application changes only. There is **no +automated data migration** from an existing Supabase instance — the type +changes (uuid, jsonb, timestamptz) and the auth model change mean rows cannot be +copied verbatim, and password hashes held by Supabase Auth cannot be exported in +a form scrypt can verify. Existing users would need to reset their passwords. diff --git a/migrations/0001_core_tables.sql b/migrations/0001_core_tables.sql new file mode 100644 index 0000000..5f33791 --- /dev/null +++ b/migrations/0001_core_tables.sql @@ -0,0 +1,171 @@ +-- MeshHook core tables (SQLite / libSQL). +-- +-- Ported from supabase/migrations/20250110000001_create_core_tables.sql plus the +-- later column additions (…0006_add_workflow_metadata_columns). Type mapping: +-- +-- uuid -> text, default is a v4 generated in SQL (see uuid4 note below) +-- jsonb -> text holding JSON; query with json_extract() +-- timestamptz -> text holding ISO-8601 UTC, e.g. 2026-08-11T12:00:00.000Z +-- bytea -> blob +-- bigserial -> integer primary key (an alias for rowid, so it autoincrements) +-- inet -> text +-- +-- Row Level Security has no SQLite equivalent. Every policy that previously +-- lived in the database is now enforced in application code — see +-- packages/shared/lib/authz.js. Any new query that touches a project-scoped +-- table must go through those helpers or filter on owner explicitly. +-- +-- The default id expression builds an RFC-4122 v4 UUID from randomblob() so that +-- inserts which omit `id` keep working the way they did under gen_random_uuid(). + +create table if not exists projects ( + id text primary key default ( + lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || + substr(hex(randomblob(2)), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || + substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))) + ), + owner text not null, + name text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create index if not exists idx_projects_owner on projects(owner); + +create trigger if not exists update_projects_updated_at +after update on projects for each row +begin + update projects set updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + where id = new.id; +end; + +-- Encrypted secrets vault. value_encrypted stays AES-GCM ciphertext; SQLite +-- stores it as a blob exactly as Postgres stored the bytea. +create table if not exists secrets ( + id text primary key default ( + lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || + substr(hex(randomblob(2)), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || + substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))) + ), + project_id text not null references projects(id) on delete cascade, + key text not null, + value_encrypted blob not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + unique(project_id, key) +); + +create index if not exists idx_secrets_project_id on secrets(project_id); + +create trigger if not exists update_secrets_updated_at +after update on secrets for each row +begin + update secrets set updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + where id = new.id; +end; + +-- Workflow definitions, versioned. `name` is not null in the Postgres schema by +-- the time migration 0006 has run, so it is declared not null here directly. +create table if not exists workflow_definitions ( + id text primary key default ( + lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || + substr(hex(randomblob(2)), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || + substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))) + ), + project_id text not null references projects(id) on delete cascade, + slug text not null, + name text not null, + description text, + status text default 'draft' check (status in ('draft', 'published', 'archived')), + user_id text, + version integer not null default 1, + definition text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + unique(project_id, slug, version) +); + +create index if not exists idx_workflow_definitions_project_id on workflow_definitions(project_id); +create index if not exists idx_workflow_definitions_slug on workflow_definitions(project_id, slug); +create index if not exists idx_workflow_definitions_status on workflow_definitions(status); +create index if not exists idx_workflow_definitions_user_id on workflow_definitions(user_id); + +create trigger if not exists update_workflow_definitions_updated_at +after update on workflow_definitions for each row +begin + update workflow_definitions set updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + where id = new.id; +end; + +create table if not exists workflow_runs ( + id text primary key default ( + lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || + substr(hex(randomblob(2)), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || + substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))) + ), + project_id text not null references projects(id) on delete cascade, + workflow_id text not null references workflow_definitions(id) on delete cascade, + status text not null check (status in ('running', 'succeeded', 'failed', 'paused', 'canceled')), + started_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + finished_at text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create index if not exists idx_workflow_runs_project_id on workflow_runs(project_id); +create index if not exists idx_workflow_runs_workflow_id on workflow_runs(workflow_id); +create index if not exists idx_workflow_runs_status on workflow_runs(status); +create index if not exists idx_workflow_runs_project_started on workflow_runs(project_id, started_at desc); + +-- Deliberately not a generic updated_at trigger: the orchestrator sets +-- finished_at and updated_at together in one statement, and a trigger firing on +-- its own update would rewrite the value it just wrote. +create trigger if not exists update_workflow_runs_updated_at +after update of status, finished_at on workflow_runs for each row +when new.updated_at = old.updated_at +begin + update workflow_runs set updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + where id = new.id; +end; + +-- Event-sourcing log. Under Postgres this was range-partitioned by month +-- (migration …0003). SQLite has no declarative partitioning; the equivalent +-- win comes from the (run_id, ts) index, which is what every replay query uses. +-- Retention is handled by scripts/prune-events.js rather than by dropping +-- partitions. +create table if not exists workflow_events ( + id integer primary key, + run_id text not null references workflow_runs(id) on delete cascade, + ts text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + type text not null, + payload text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create index if not exists idx_workflow_events_run_id on workflow_events(run_id); +create index if not exists idx_workflow_events_run_ts on workflow_events(run_id, ts); +create index if not exists idx_workflow_events_type on workflow_events(type); +-- Supports the retention sweep, which deletes by age across all runs. +create index if not exists idx_workflow_events_ts on workflow_events(ts); + +create table if not exists audit_log ( + id integer primary key, + project_id text references projects(id) on delete cascade, + user_id text not null, + action text not null, + resource_type text not null, + resource_id text, + metadata text, + ip_address text, + user_agent text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create index if not exists idx_audit_log_project_id on audit_log(project_id); +create index if not exists idx_audit_log_user_id on audit_log(user_id); +create index if not exists idx_audit_log_created_at on audit_log(created_at desc); +create index if not exists idx_audit_log_action on audit_log(action); diff --git a/migrations/0002_auth.sql b/migrations/0002_auth.sql new file mode 100644 index 0000000..136a124 --- /dev/null +++ b/migrations/0002_auth.sql @@ -0,0 +1,78 @@ +-- Authentication tables. +-- +-- Supabase provided auth.users, the session/JWT machinery and the auth.uid() +-- function used throughout the old RLS policies. None of that exists on Turso, +-- so MeshHook now owns its user and session storage. Password hashing and +-- session issuance live in packages/shared/lib/auth.js. +-- +-- Columns that Supabase's auth.users exposed and application code read +-- (id, email, created_at) keep their names so call sites read the same. + +create table if not exists users ( + id text primary key default ( + lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || + substr(hex(randomblob(2)), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || + substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))) + ), + -- Stored lower-cased; the unique index is therefore already case-insensitive + -- without needing a collation. + email text not null unique, + -- scrypt output, encoded as "scrypt$N$r$p$salt$hash". Null only for accounts + -- created by an external identity provider. + password_hash text, + email_verified integer not null default 0 check (email_verified in (0, 1)), + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create index if not exists idx_users_email on users(email); + +create trigger if not exists update_users_updated_at +after update on users for each row +begin + update users set updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + where id = new.id; +end; + +-- Opaque session tokens. Only the SHA-256 of the token is stored, so a database +-- leak does not hand out live sessions; the plaintext exists solely in the +-- user's cookie. +create table if not exists sessions ( + id text primary key, + user_id text not null references users(id) on delete cascade, + expires_at text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_seen_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + user_agent text, + ip_address text +); + +create index if not exists idx_sessions_user_id on sessions(user_id); +-- Drives the expired-session sweep. +create index if not exists idx_sessions_expires_at on sessions(expires_at); + +-- Per-user UI preferences. Previously referenced auth.users(id); now points at +-- the local users table. +create table if not exists user_settings ( + id text primary key default ( + lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || + substr(hex(randomblob(2)), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || + substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))) + ), + user_id text not null unique references users(id) on delete cascade, + theme_preference text default 'light' check (theme_preference in ('light', 'dark')), + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create index if not exists idx_user_settings_user_id on user_settings(user_id); +create index if not exists idx_user_settings_theme on user_settings(theme_preference); + +create trigger if not exists update_user_settings_updated_at +after update on user_settings for each row +begin + update user_settings set updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + where id = new.id; +end; diff --git a/migrations/0003_queue.sql b/migrations/0003_queue.sql new file mode 100644 index 0000000..0196896 --- /dev/null +++ b/migrations/0003_queue.sql @@ -0,0 +1,117 @@ +-- Job queue. +-- +-- Replaces the pgmq extension (supabase/migrations/…0004_setup_pgmq_queues.sql), +-- which does not exist for SQLite. pgmq created a table pair per queue +-- (pgmq.q_ / pgmq.a_); here a single table carries a queue_name +-- column, because SQLite has no schemas and creating tables at runtime would +-- fight the migration runner. +-- +-- The visibility-timeout model is preserved exactly: a message is available +-- when vt <= now, a read pushes vt into the future and bumps read_ct, and an +-- unacknowledged message becomes visible again when its lease lapses. The +-- claim itself is done in a transaction in packages/shared/lib/queue.js, since +-- the plpgsql that used to guarantee atomicity is gone. + +create table if not exists queue_messages ( + -- integer primary key => rowid alias, so msg_id autoincrements like pgmq's + -- bigserial and stays comparable for FIFO ordering. + msg_id integer primary key, + queue_name text not null, + message text not null, + read_ct integer not null default 0, + enqueued_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + -- Visibility time: the message is invisible to readers until this instant. + -- A delayed send sets it forward; an immediate send sets it to now. + vt text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- The dequeue hot path: find the oldest visible message on one queue. +create index if not exists idx_queue_messages_claim on queue_messages(queue_name, vt, msg_id); + +-- Archived messages. pgmq.archive() moved rows to a_; this keeps the +-- original msg_id so job_tracking rows still join. +create table if not exists queue_archive ( + msg_id integer primary key, + queue_name text not null, + message text not null, + read_ct integer not null default 0, + enqueued_at text not null, + archived_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create index if not exists idx_queue_archive_queue_name on queue_archive(queue_name); +create index if not exists idx_queue_archive_archived_at on queue_archive(archived_at desc); + +create table if not exists queue_config ( + id text primary key default ( + lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || + substr(hex(randomblob(2)), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || + substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))) + ), + queue_name text not null unique, + visibility_timeout_seconds integer not null default 30, + max_retry_attempts integer not null default 5, + retry_backoff_base_ms integer not null default 1000, + retry_backoff_max_ms integer not null default 300000, + dlq_enabled integer not null default 1 check (dlq_enabled in (0, 1)), + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create trigger if not exists update_queue_config_updated_at +after update on queue_config for each row +begin + update queue_config set updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + where id = new.id; +end; + +insert into queue_config ( + queue_name, visibility_timeout_seconds, max_retry_attempts, + retry_backoff_base_ms, retry_backoff_max_ms, dlq_enabled +) values ('workflow_jobs', 30, 5, 1000, 300000, 1) +on conflict (queue_name) do nothing; + +-- The DLQ is a terminal destination: nothing retries out of it automatically, +-- hence zero attempts and no onward DLQ. +insert into queue_config ( + queue_name, visibility_timeout_seconds, max_retry_attempts, + retry_backoff_base_ms, retry_backoff_max_ms, dlq_enabled +) values ('workflow_jobs_dlq', 300, 0, 0, 0, 0) +on conflict (queue_name) do nothing; + +create table if not exists job_tracking ( + id text primary key default ( + lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || + substr(hex(randomblob(2)), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || + substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))) + ), + msg_id integer not null, + run_id text not null references workflow_runs(id) on delete cascade, + queue_name text not null, + attempt integer not null default 1, + max_attempts integer not null default 5, + enqueued_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + started_at text, + completed_at text, + failed_at text, + moved_to_dlq_at text, + error_message text, + error_stack text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create index if not exists idx_job_tracking_msg_id on job_tracking(msg_id); +create index if not exists idx_job_tracking_run_id on job_tracking(run_id); +create index if not exists idx_job_tracking_queue_name on job_tracking(queue_name); +create index if not exists idx_job_tracking_enqueued_at on job_tracking(enqueued_at desc); +create index if not exists idx_job_tracking_status on job_tracking(completed_at, failed_at, moved_to_dlq_at); + +create trigger if not exists update_job_tracking_updated_at +after update on job_tracking for each row +begin + update job_tracking set updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + where id = new.id; +end; diff --git a/migrations/0004_views.sql b/migrations/0004_views.sql new file mode 100644 index 0000000..3d28cce --- /dev/null +++ b/migrations/0004_views.sql @@ -0,0 +1,29 @@ +-- Compatibility views. +-- +-- `workflows` presents workflow_definitions under the name the application +-- layer uses. Under Postgres this view was declared `with (security_invoker = +-- true)` so RLS applied to the caller (…0009_fix_workflows_view_rls.sql). +-- SQLite has neither RLS nor security_invoker, so the view is a plain +-- projection and the ownership check moved into the query helpers in +-- packages/shared/lib/authz.js. +-- +-- Note this view is read-only. Postgres granted insert/update/delete on it via +-- rules; SQLite would need INSTEAD OF triggers, and no call site writes through +-- the view, so writes go directly to workflow_definitions. + +drop view if exists workflows; + +create view workflows as +select + id, + project_id, + slug, + name, + description, + status, + user_id, + version, + definition, + created_at, + updated_at +from workflow_definitions; diff --git a/package.json b/package.json index db871bb..10da844 100644 --- a/package.json +++ b/package.json @@ -3,31 +3,37 @@ "private": true, "type": "module", "license": "MIT", + "packageManager": "pnpm@11.18.0", "scripts": { "mh": "node packages/cli/bin/mh.js", - "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm && echo \"✅ MeshHook workspace installed\"", + "postinstall": "node scripts/socket-patch.mjs", "format": "prettier -w .", "setup": "node scripts/setup.js", "dev": "cd apps/web && pnpm run dev", "build": "cd apps/web && pnpm run build", "db:migrate": "node scripts/db-migrate.js", - "db:migrate:local": "pnpx supabase migrations up", - "db:migrate:production": "pnpx supabase db push --linked --include-all", "db:verify": "node scripts/verify-migration.js", "issue:progress": "node scripts/gh-project-status.js", "issue:done": "node scripts/gh-project-status.js", "start": "node workers/orchestrator.mjs", - "dependencies": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm" + "db:status": "node scripts/db-migrate.js --status", + "test": "pnpm run test:vitest && pnpm run test:node", + "test:watch": "vitest", + "test:node": "node --test src/nodes/*.test.js src/workers/*.test.js src/utils/*.test.js", + "test:vitest": "vitest run", + "security:patch": "SOCKET_API_TOKEN=${SOCKET_API_TOKEN} node scripts/socket-patch.mjs" }, "devDependencies": { "dotenv": "^17.2.3", "inquirer": "^12.9.6", - "pg": "^8.16.3", - "prettier": "^3.3.3" + "prettier": "^3.3.3", + "vitest": "^3.2.4", + "chai": "^5.1.2" }, "dependencies": { "fast-xml-parser": "^5.3.0", "jmespath": "^0.16.0", - "openai": "^6.3.0" + "openai": "^6.3.0", + "@meshhook/shared": "workspace:*" } } diff --git a/packages/shared/lib/auth.js b/packages/shared/lib/auth.js new file mode 100644 index 0000000..1908a26 --- /dev/null +++ b/packages/shared/lib/auth.js @@ -0,0 +1,271 @@ +/** + * Authentication: password hashing and session management. + * + * Supabase Auth handled all of this (signup, login, JWTs, cookie refresh, + * auth.uid() inside RLS policies). Turso is just a database, so MeshHook owns + * it now. The design is deliberately small: + * + * - Passwords are hashed with scrypt from node:crypto. No new dependency, and + * it is memory-hard, unlike a bare SHA. + * - Sessions are opaque 256-bit random tokens. The database stores only the + * SHA-256 of the token, so a database leak cannot be replayed as a login. + * - Session ids are compared with timingSafeEqual via the hash lookup rather + * than by string equality on the raw token. + * + * There is no JWT: a session is a database row, so revocation is a DELETE and + * takes effect immediately, which the old stateless access tokens could not do. + */ + +import { + randomBytes, + scrypt as scryptCallback, + timingSafeEqual, + createHash, +} from "node:crypto"; +import { promisify } from "node:util"; +import { db as sharedDb } from "./db.js"; + +const scrypt = promisify(scryptCallback); + +// scrypt cost parameters. N=16384 keeps a hash around 50-100ms on typical +// hardware — slow enough to blunt offline cracking, fast enough for a login. +const SCRYPT_N = 16384; +const SCRYPT_r = 8; +const SCRYPT_p = 1; +const KEY_LENGTH = 64; +const SALT_LENGTH = 16; + +/** How long a new session stays valid. */ +export const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +/** + * Sliding-window refresh: a session more than this far from issue gets its + * expiry extended when used, so active users are not logged out mid-session + * while idle sessions still expire. + */ +const SESSION_REFRESH_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 1 day + +/** Name of the cookie carrying the session token. */ +export const SESSION_COOKIE = "meshhook_session"; + +/** + * Hash a password into "scrypt$N$r$p$salt$hash" (base64 salt and hash). + * The parameters travel with the hash so they can be raised later without + * invalidating existing passwords. + */ +export async function hashPassword(password) { + if (typeof password !== "string" || password.length === 0) { + throw new Error("Password must be a non-empty string"); + } + + const salt = randomBytes(SALT_LENGTH); + const derived = await scrypt(password, salt, KEY_LENGTH, { + N: SCRYPT_N, + r: SCRYPT_r, + p: SCRYPT_p, + // scrypt needs memory ≈ 128*N*r bytes; Node's default cap is below that. + maxmem: 256 * SCRYPT_N * SCRYPT_r, + }); + + return [ + "scrypt", + SCRYPT_N, + SCRYPT_r, + SCRYPT_p, + salt.toString("base64"), + derived.toString("base64"), + ].join("$"); +} + +/** + * Check a password against a stored hash. + * + * Always returns a boolean — a malformed stored hash is a failed verification, + * not an exception, so it cannot be used to distinguish accounts. + */ +export async function verifyPassword(password, stored) { + if (typeof password !== "string" || typeof stored !== "string") return false; + + const parts = stored.split("$"); + if (parts.length !== 6 || parts[0] !== "scrypt") return false; + + const [, n, r, p, saltB64, hashB64] = parts; + + try { + const salt = Buffer.from(saltB64, "base64"); + const expected = Buffer.from(hashB64, "base64"); + + const derived = await scrypt(password, salt, expected.length, { + N: Number(n), + r: Number(r), + p: Number(p), + maxmem: 256 * Number(n) * Number(r), + }); + + // Equal lengths are required by timingSafeEqual. + if (derived.length !== expected.length) return false; + return timingSafeEqual(derived, expected); + } catch { + return false; + } +} + +/** Normalise an email for storage and lookup. */ +export const normalizeEmail = (email) => String(email ?? "").trim().toLowerCase(); + +/** SHA-256 of a session token; this is what the sessions table stores. */ +const hashToken = (token) => createHash("sha256").update(token).digest("hex"); + +/** + * Register a new user. + * @throws when the email is already taken. + */ +export async function createUser({ email, password }, db = sharedDb) { + const normalized = normalizeEmail(email); + + if (!normalized || !normalized.includes("@")) { + throw new Error("A valid email address is required"); + } + if (typeof password !== "string" || password.length < 8) { + throw new Error("Password must be at least 8 characters"); + } + + const existing = await db.oneOrNone("select id from users where email = ?", [normalized]); + if (existing) { + throw new Error("An account with that email already exists"); + } + + const passwordHash = await hashPassword(password); + + return db.one( + `insert into users (email, password_hash) values (?, ?) + returning id, email, created_at`, + [normalized, passwordHash], + ); +} + +/** + * Verify credentials. + * + * Runs the hash comparison even when no such user exists, so the response time + * does not reveal whether an email is registered. + * @returns {Promise} The user, or null when credentials are wrong. + */ +export async function authenticate({ email, password }, db = sharedDb) { + const normalized = normalizeEmail(email); + + const user = await db.oneOrNone( + "select id, email, password_hash, created_at from users where email = ?", + [normalized], + ); + + // A dummy hash of the right shape keeps the work comparable for unknown users. + const stored = + user?.password_hash ?? + "scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$" + "A".repeat(88); + + const ok = await verifyPassword(password, stored); + + if (!user || !ok) return null; + + return { id: user.id, email: user.email, created_at: user.created_at }; +} + +/** + * Issue a session and return the raw token to put in a cookie. + * + * The raw token is returned once and never stored; only its hash is persisted. + */ +export async function createSession(userId, { userAgent, ipAddress } = {}, db = sharedDb) { + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + SESSION_DURATION_MS).toISOString(); + + await db.none( + `insert into sessions (id, user_id, expires_at, user_agent, ip_address) + values (?, ?, ?, ?, ?)`, + [hashToken(token), userId, expiresAt, userAgent ?? null, ipAddress ?? null], + ); + + return { token, expiresAt }; +} + +/** + * Resolve a session token to its user. + * + * Expired sessions are deleted on sight rather than merely rejected, which + * keeps the table from accumulating dead rows without a separate sweep. + * + * @returns {Promise<{user: object, session: object}|null>} + */ +export async function validateSession(token, db = sharedDb) { + if (!token) return null; + + const id = hashToken(token); + + const row = await db.oneOrNone( + `select s.id, s.user_id, s.expires_at, s.created_at, + u.email, u.created_at as user_created_at + from sessions s + join users u on u.id = s.user_id + where s.id = ?`, + [id], + ); + + if (!row) return null; + + if (Date.parse(row.expires_at) <= Date.now()) { + await db.none("delete from sessions where id = ?", [id]); + return null; + } + + // Extend a session that is being actively used. + const remaining = Date.parse(row.expires_at) - Date.now(); + let expiresAt = row.expires_at; + + if (remaining < SESSION_DURATION_MS - SESSION_REFRESH_THRESHOLD_MS) { + expiresAt = new Date(Date.now() + SESSION_DURATION_MS).toISOString(); + await db.none( + `update sessions + set expires_at = ?, last_seen_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + where id = ?`, + [expiresAt, id], + ); + } + + return { + user: { id: row.user_id, email: row.email, created_at: row.user_created_at }, + session: { id: row.id, expires_at: expiresAt }, + }; +} + +/** Revoke a single session. */ +export async function destroySession(token, db = sharedDb) { + if (!token) return false; + const { rowsAffected } = await db.none("delete from sessions where id = ?", [hashToken(token)]); + return rowsAffected > 0; +} + +/** Revoke every session for a user, e.g. after a password change. */ +export async function destroyUserSessions(userId, db = sharedDb) { + const { rowsAffected } = await db.none("delete from sessions where user_id = ?", [userId]); + return rowsAffected; +} + +/** Delete expired sessions. Intended for a periodic job. */ +export async function pruneExpiredSessions(db = sharedDb) { + const { rowsAffected } = await db.none( + "delete from sessions where expires_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", + ); + return rowsAffected; +} + +/** Cookie options for the session cookie. Secure is disabled only for http dev. */ +export function sessionCookieOptions({ secure = true } = {}) { + return { + path: "/", + httpOnly: true, + sameSite: "lax", + secure, + maxAge: Math.floor(SESSION_DURATION_MS / 1000), + }; +} diff --git a/packages/shared/lib/auth.test.js b/packages/shared/lib/auth.test.js new file mode 100644 index 0000000..d7bc3a1 --- /dev/null +++ b/packages/shared/lib/auth.test.js @@ -0,0 +1,287 @@ +// Auth tests: password hashing, session lifecycle, and tenant scoping. + +import { randomBytes } from "node:crypto"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + hashPassword, + verifyPassword, + createUser, + authenticate, + createSession, + validateSession, + destroySession, + destroyUserSessions, + pruneExpiredSessions, + normalizeEmail, +} from "./auth.js"; +import { + assertProjectAccess, + getWorkflow, + listWorkflows, + listRunEvents, + ensureDefaultProject, + NotFoundError, +} from "./authz.js"; +import { createTestDb } from "../../../src/queue/test-helpers.js"; + +/** + * Passwords used by these tests, generated per run. + * + * They were literals ("password123"), which secret scanners flag as hardcoded + * credentials — seven findings on this file alone, burying anything real in + * noise. Generating them keeps the fixtures out of the scanner's way and means + * no test can accidentally depend on a specific secret value. + */ +const VALID_PASSWORD = `pw-${randomBytes(12).toString("hex")}`; +const WRONG_PASSWORD = `wrong-${randomBytes(12).toString("hex")}`; + +describe("password hashing", () => { + it("verifies a correct password", async () => { + const hash = await hashPassword(VALID_PASSWORD); + expect(await verifyPassword(VALID_PASSWORD, hash)).toBe(true); + }); + + it("rejects an incorrect password", async () => { + const hash = await hashPassword(VALID_PASSWORD); + expect(await verifyPassword(WRONG_PASSWORD, hash)).toBe(false); + }); + + it("salts each hash, so equal passwords differ", async () => { + expect(await hashPassword(VALID_PASSWORD)).not.toBe(await hashPassword(VALID_PASSWORD)); + }); + + it("encodes its parameters so they can be raised later", async () => { + const hash = await hashPassword(VALID_PASSWORD); + expect(hash.split("$").slice(0, 4)).toEqual(["scrypt", "16384", "8", "1"]); + }); + + it("never stores the password itself", async () => { + const hash = await hashPassword(VALID_PASSWORD); + expect(hash).not.toContain(VALID_PASSWORD); + }); + + it("returns false rather than throwing on a malformed hash", async () => { + for (const bad of ["", "garbage", "scrypt$1$2", "bcrypt$16384$8$1$aa$bb"]) { + expect(await verifyPassword(VALID_PASSWORD, bad)).toBe(false); + } + }); + + it("rejects an empty password at hash time", async () => { + await expect(hashPassword("")).rejects.toThrow(/non-empty/); + }); +}); + +describe("normalizeEmail", () => { + it("lowercases and trims", () => { + expect(normalizeEmail(" User@Example.COM ")).toBe("user@example.com"); + }); +}); + +describe("users and sessions", () => { + let db; + + beforeEach(async () => { + db = await createTestDb(); + }); + + afterEach(async () => { + await db.close(); + }); + + const signup = (email = "user@example.com", password = VALID_PASSWORD) => + createUser({ email, password }, db); + + describe("createUser", () => { + it("stores the email lower-cased", async () => { + const user = await signup("Mixed@Case.COM"); + expect(user.email).toBe("mixed@case.com"); + expect(user.id).toBeTruthy(); + }); + + it("rejects a duplicate email regardless of case", async () => { + await signup("dupe@example.com"); + await expect(signup("DUPE@example.com")).rejects.toThrow(/already exists/); + }); + + it("rejects an invalid email", async () => { + await expect(signup("not-an-email")).rejects.toThrow(/valid email/); + }); + + it("rejects a short password", async () => { + await expect(signup("a@b.com", "short")).rejects.toThrow(/at least 8/); + }); + + it("does not return the password hash", async () => { + const user = await signup(); + expect(user.password_hash).toBeUndefined(); + }); + }); + + describe("authenticate", () => { + it("accepts correct credentials", async () => { + const created = await signup(); + const user = await authenticate({ email: "user@example.com", password: VALID_PASSWORD }, db); + expect(user?.id).toBe(created.id); + }); + + it("is case-insensitive on the email", async () => { + await signup("user@example.com"); + const user = await authenticate({ email: "USER@EXAMPLE.COM", password: VALID_PASSWORD }, db); + expect(user).not.toBeNull(); + }); + + it("rejects a wrong password", async () => { + await signup(); + expect(await authenticate({ email: "user@example.com", password: WRONG_PASSWORD }, db)).toBeNull(); + }); + + it("returns null for an unknown user instead of throwing", async () => { + expect(await authenticate({ email: "ghost@example.com", password: WRONG_PASSWORD }, db)).toBeNull(); + }); + }); + + describe("sessions", () => { + it("issues a token that validates back to the user", async () => { + const user = await signup(); + const { token } = await createSession(user.id, {}, db); + + const result = await validateSession(token, db); + expect(result?.user.id).toBe(user.id); + expect(result?.user.email).toBe("user@example.com"); + }); + + it("stores only the hash of the token", async () => { + const user = await signup(); + const { token } = await createSession(user.id, {}, db); + + const row = await db.one("select id from sessions where user_id = ?", [user.id]); + // The raw token must not be recoverable from the database. + expect(row.id).not.toBe(token); + expect(row.id).toHaveLength(64); // sha256 hex + }); + + it("rejects an unknown or empty token", async () => { + expect(await validateSession("nonsense", db)).toBeNull(); + expect(await validateSession("", db)).toBeNull(); + expect(await validateSession(null, db)).toBeNull(); + }); + + it("rejects and deletes an expired session", async () => { + const user = await signup(); + const { token } = await createSession(user.id, {}, db); + + await db.none("update sessions set expires_at = ? where user_id = ?", [ + new Date(Date.now() - 1000).toISOString(), + user.id, + ]); + + expect(await validateSession(token, db)).toBeNull(); + const remaining = await db.manyOrNone("select id from sessions where user_id = ?", [user.id]); + expect(remaining).toHaveLength(0); + }); + + it("destroys a single session", async () => { + const user = await signup(); + const { token } = await createSession(user.id, {}, db); + + expect(await destroySession(token, db)).toBe(true); + expect(await validateSession(token, db)).toBeNull(); + }); + + it("destroys every session for a user", async () => { + const user = await signup(); + const a = await createSession(user.id, {}, db); + const b = await createSession(user.id, {}, db); + + expect(await destroyUserSessions(user.id, db)).toBe(2); + expect(await validateSession(a.token, db)).toBeNull(); + expect(await validateSession(b.token, db)).toBeNull(); + }); + + it("prunes only expired sessions", async () => { + const user = await signup(); + const live = await createSession(user.id, {}, db); + const dead = await createSession(user.id, {}, db); + + await db.none("update sessions set expires_at = ? where id != ?", [ + new Date(Date.now() - 1000).toISOString(), + // keep the live one untouched + (await validateSession(live.token, db)).session.id, + ]); + + expect(await pruneExpiredSessions(db)).toBe(1); + expect(await validateSession(live.token, db)).not.toBeNull(); + expect(await validateSession(dead.token, db)).toBeNull(); + }); + + it("cascades session deletion when the user is removed", async () => { + const user = await signup(); + const { token } = await createSession(user.id, {}, db); + + await db.none("delete from users where id = ?", [user.id]); + expect(await validateSession(token, db)).toBeNull(); + }); + }); +}); + +describe("authz tenant scoping", () => { + let db; + let alice; + let bob; + let aliceProject; + let aliceWorkflow; + + beforeEach(async () => { + db = await createTestDb(); + alice = await createUser({ email: "alice@example.com", password: VALID_PASSWORD }, db); + bob = await createUser({ email: "bob@example.com", password: VALID_PASSWORD }, db); + + aliceProject = await ensureDefaultProject(alice.id, "Alice", db); + aliceWorkflow = await db.one( + `insert into workflow_definitions (project_id, slug, name, definition) + values (?, ?, ?, ?) returning id`, + [aliceProject, "wf", "Workflow", JSON.stringify({ nodes: [] })], + ); + }); + + afterEach(async () => { + await db.close(); + }); + + it("creates a default project once and reuses it", async () => { + expect(await ensureDefaultProject(alice.id, "Alice", db)).toBe(aliceProject); + }); + + it("lets the owner read their own project", async () => { + await expect(assertProjectAccess(alice.id, aliceProject, db)).resolves.toBe(aliceProject); + }); + + it("hides another user's project behind NotFound", async () => { + // Reporting 403 here would confirm the id exists. + await expect(assertProjectAccess(bob.id, aliceProject, db)).rejects.toThrow(NotFoundError); + }); + + it("does not leak a workflow across tenants", async () => { + expect(await getWorkflow(alice.id, aliceWorkflow.id, db)).not.toBeNull(); + expect(await getWorkflow(bob.id, aliceWorkflow.id, db)).toBeNull(); + }); + + it("lists only the caller's workflows", async () => { + expect(await listWorkflows(alice.id, {}, db)).toHaveLength(1); + expect(await listWorkflows(bob.id, {}, db)).toHaveLength(0); + }); + + it("does not leak run events across tenants", async () => { + const run = await db.one( + `insert into workflow_runs (project_id, workflow_id, status) + values (?, ?, 'running') returning id`, + [aliceProject, aliceWorkflow.id], + ); + await db.none(`insert into workflow_events (run_id, type, payload) values (?, 'x', '{}')`, [ + run.id, + ]); + + expect(await listRunEvents(alice.id, run.id, {}, db)).toHaveLength(1); + expect(await listRunEvents(bob.id, run.id, {}, db)).toHaveLength(0); + }); +}); diff --git a/packages/shared/lib/authz.js b/packages/shared/lib/authz.js new file mode 100644 index 0000000..e00e5c2 --- /dev/null +++ b/packages/shared/lib/authz.js @@ -0,0 +1,226 @@ +/** + * Authorization helpers — the application-layer replacement for Row Level + * Security. + * + * Under Supabase, every table carried RLS policies keyed on auth.uid(), so a + * query that forgot to filter by owner still returned only that user's rows. + * SQLite has no RLS and Turso has no notion of the calling user, so *that + * safety net is gone*: an unfiltered query now returns every tenant's data. + * + * The policies being replaced (supabase/migrations/…0002_enable_rls_policies) + * reduced to one rule applied consistently: + * + * projects owner = current user + * secrets project_id ∈ the user's projects + * workflow_definitions project_id ∈ the user's projects + * workflow_runs project_id ∈ the user's projects + * workflow_events run_id → workflow_runs → the user's projects + * audit_log project_id ∈ the user's projects + * + * Route handlers should go through these helpers rather than writing the join + * by hand. Where a bespoke query is unavoidable, use ownedProjectIdsSql() so + * the scoping predicate stays in one place. + */ + +import { db as sharedDb } from "./db.js"; + +/** + * A subquery selecting the project ids owned by one user, for embedding in a + * larger statement. Takes one bound parameter: the user id. + * + * The Postgres equivalent was the user_project_ids() function referenced by + * every policy. + */ +export const ownedProjectIdsSql = () => "select id from projects where owner = ?"; + +/** Raised when a record exists but belongs to someone else. */ +export class ForbiddenError extends Error { + constructor(message = "You do not have access to this resource") { + super(message); + this.name = "ForbiddenError"; + this.status = 403; + } +} + +/** Raised when a record does not exist. */ +export class NotFoundError extends Error { + constructor(message = "Resource not found") { + super(message); + this.name = "NotFoundError"; + this.status = 404; + } +} + +/** Every project owned by a user. */ +export async function listProjects(userId, db = sharedDb) { + return db.manyOrNone( + "select id, name, owner, created_at, updated_at from projects where owner = ? order by created_at", + [userId], + ); +} + +/** + * Assert that `userId` owns `projectId`. + * + * Deliberately reports NotFound for a project owned by someone else, so the + * error does not confirm that the id exists. + */ +export async function assertProjectAccess(userId, projectId, db = sharedDb) { + const row = await db.oneOrNone("select id from projects where id = ? and owner = ?", [ + projectId, + userId, + ]); + if (!row) throw new NotFoundError("Project not found"); + return row.id; +} + +/** + * Fetch a workflow the user is allowed to see. + * @returns {Promise} + */ +export async function getWorkflow(userId, workflowId, db = sharedDb) { + return db.oneOrNone( + `select w.* from workflow_definitions w + where w.id = ? and w.project_id in (${ownedProjectIdsSql()})`, + [workflowId, userId], + ); +} + +/** Every workflow across the user's projects, newest first. */ +export async function listWorkflows(userId, { limit = 100, offset = 0 } = {}, db = sharedDb) { + return db.manyOrNone( + `select w.* from workflow_definitions w + where w.project_id in (${ownedProjectIdsSql()}) + order by w.updated_at desc + limit ? offset ?`, + [userId, limit, offset], + ); +} + +/** Fetch a run the user is allowed to see. */ +export async function getRun(userId, runId, db = sharedDb) { + return db.oneOrNone( + `select r.* from workflow_runs r + where r.id = ? and r.project_id in (${ownedProjectIdsSql()})`, + [runId, userId], + ); +} + +/** Runs across the user's projects, most recently started first. */ +export async function listRuns(userId, { limit = 50, offset = 0, status } = {}, db = sharedDb) { + const params = [userId]; + let statusFilter = ""; + + if (status) { + statusFilter = "and r.status = ?"; + params.push(status); + } + params.push(limit, offset); + + return db.manyOrNone( + `select r.*, w.name as workflow_name, w.slug as workflow_slug + from workflow_runs r + join workflow_definitions w on w.id = r.workflow_id + where r.project_id in (${ownedProjectIdsSql()}) ${statusFilter} + order by r.started_at desc + limit ? offset ?`, + params, + ); +} + +/** + * Events for a run, scoped through the run's project. + * + * `afterId` supports incremental polling from the live log view without + * re-reading the whole history. + */ +export async function listRunEvents( + userId, + runId, + { limit = 500, afterId = 0 } = {}, + db = sharedDb, +) { + return db.manyOrNone( + `select e.id, e.run_id, e.ts, e.type, e.payload + from workflow_events e + join workflow_runs r on r.id = e.run_id + where e.run_id = ? + and e.id > ? + and r.project_id in (${ownedProjectIdsSql()}) + order by e.id asc + limit ?`, + [runId, afterId, userId, limit], + ); +} + +/** Secrets for a project, without their ciphertext. */ +export async function listSecrets(userId, projectId, db = sharedDb) { + await assertProjectAccess(userId, projectId, db); + return db.manyOrNone( + `select id, project_id, key, created_at, updated_at + from secrets where project_id = ? order by key`, + [projectId], + ); +} + +/** + * Fetch one secret, ciphertext included. Callers are responsible for + * decrypting and for not returning the plaintext to the browser. + */ +export async function getSecret(userId, secretId, db = sharedDb) { + return db.oneOrNone( + `select s.* from secrets s + where s.id = ? and s.project_id in (${ownedProjectIdsSql()})`, + [secretId, userId], + ); +} + +/** + * Record an auditable action. + * + * The RLS policy allowed inserts only into the user's own projects; the same + * check is made here before writing. + */ +export async function recordAudit( + userId, + { projectId, action, resourceType, resourceId, metadata, ipAddress, userAgent }, + db = sharedDb, +) { + if (projectId) { + await assertProjectAccess(userId, projectId, db); + } + + await db.none( + `insert into audit_log + (project_id, user_id, action, resource_type, resource_id, metadata, ip_address, user_agent) + values (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + projectId ?? null, + userId, + action, + resourceType, + resourceId ?? null, + metadata ? JSON.stringify(metadata) : null, + ipAddress ?? null, + userAgent ?? null, + ], + ); +} + +/** + * Ensure the user has a project to work in, creating a default one on first + * use. Supabase seeded this with a trigger on auth.users. + */ +export async function ensureDefaultProject(userId, name = "Default", db = sharedDb) { + const existing = await db.oneOrNone( + "select id from projects where owner = ? order by created_at limit 1", + [userId], + ); + if (existing) return existing.id; + + const created = await db.one("insert into projects (owner, name) values (?, ?) returning id", [ + userId, + name, + ]); + return created.id; +} diff --git a/packages/shared/lib/crypto.js b/packages/shared/lib/crypto.js new file mode 100644 index 0000000..f5ef262 --- /dev/null +++ b/packages/shared/lib/crypto.js @@ -0,0 +1,123 @@ +/** + * Secret encryption (AES-256-GCM). + * + * The secrets table stores ciphertext in value_encrypted. The old web route + * inserted the plaintext with a comment saying "encryption handled by database + * trigger", but no such trigger existed in any migration — secrets were being + * written in the clear. Postgres could at least have done this with pgcrypto; + * SQLite has no equivalent, so encryption lives here and is unavoidable on the + * write path. + * + * Format: v1::: + * The version prefix leaves room to rotate the scheme later. + */ + +import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; // 96 bits, the standard nonce size for GCM +const KEY_LENGTH = 32; +const VERSION = "v1"; + +/** + * Resolve the encryption key from the environment. + * + * Accepts a 64-character hex string or a 32-byte base64 value. Any other input + * is hashed to the right length rather than being rejected, so an operator + * passing a long passphrase still gets a usable key — but a short or missing + * one is a hard error, because silently encrypting with a weak key is worse + * than failing to start. + */ +export function resolveKey(env = process.env) { + const raw = env.SECRETS_ENCRYPTION_KEY ?? env.MESHHOOK_ENCRYPTION_KEY; + + if (!raw) { + throw new Error( + "SECRETS_ENCRYPTION_KEY is not set. Generate one with: " + + "openssl rand -hex 32", + ); + } + + if (/^[0-9a-f]{64}$/i.test(raw)) { + return Buffer.from(raw, "hex"); + } + + const asBase64 = Buffer.from(raw, "base64"); + if (asBase64.length === KEY_LENGTH) { + return asBase64; + } + + if (raw.length < 32) { + throw new Error( + "SECRETS_ENCRYPTION_KEY is too short. Provide 32 bytes as hex (64 chars) " + + "or base64, or a passphrase of at least 32 characters.", + ); + } + + return createHash("sha256").update(raw).digest(); +} + +/** + * Encrypt a secret value. + * @param {string} plaintext + * @param {Buffer} [key] Defaults to the environment key. + * @returns {string} The encoded ciphertext. + */ +export function encryptSecret(plaintext, key = resolveKey()) { + if (typeof plaintext !== "string") { + throw new Error("Secret value must be a string"); + } + + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return [ + VERSION, + iv.toString("base64"), + authTag.toString("base64"), + ciphertext.toString("base64"), + ].join(":"); +} + +/** + * Decrypt a secret value. + * + * Throws when the ciphertext has been tampered with — GCM authentication + * failure is a real signal, not something to swallow and return null for. + * + * @param {string} encoded + * @param {Buffer} [key] + * @returns {string} The plaintext. + */ +export function decryptSecret(encoded, key = resolveKey()) { + if (typeof encoded !== "string") { + throw new Error("Encrypted secret must be a string"); + } + + const parts = encoded.split(":"); + if (parts.length !== 4 || parts[0] !== VERSION) { + throw new Error("Malformed encrypted secret"); + } + + const [, ivB64, tagB64, dataB64] = parts; + const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivB64, "base64")); + decipher.setAuthTag(Buffer.from(tagB64, "base64")); + + return Buffer.concat([ + decipher.update(Buffer.from(dataB64, "base64")), + decipher.final(), + ]).toString("utf8"); +} + +/** True when the environment is configured well enough to store secrets. */ +export function encryptionConfigured(env = process.env) { + try { + resolveKey(env); + return true; + } catch { + return false; + } +} diff --git a/packages/shared/lib/db.js b/packages/shared/lib/db.js index 9353dc2..8b92f87 100644 --- a/packages/shared/lib/db.js +++ b/packages/shared/lib/db.js @@ -1,45 +1,442 @@ -import pg from "pg"; +/** + * MeshHook database layer — libSQL / Turso. + * + * Replaces the previous node-postgres pool. The exported `db` object keeps the + * same shape it had under Postgres (one / oneOrNone / manyOrNone / none / tx) + * so call sites did not have to change, but two things differ underneath: + * + * - Placeholders are `?`, not `$1`. `$n` style is still accepted and rewritten + * so migrated SQL keeps working; see toLibsqlSql(). + * - SQLite has no jsonb/uuid/timestamptz. JSON columns come back as TEXT, so + * use the json() helper when reading them. + */ + +import { createClient } from "@libsql/client"; import { config } from "dotenv"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; -// Load environment variables -// In production, .env is used (not committed) -// In development, .env.local is used (committed for easy setup) const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const rootDir = join(__dirname, "../.."); +// packages/shared/lib -> repo root. The Postgres version stopped one level +// short at packages/, so the root .env was never actually read. +const rootDir = join(__dirname, "../../.."); -// Try to load .env first (production), then fall back to .env.local (development) +// .env wins (production), .env.local is the committed dev default. config({ path: join(rootDir, ".env") }); config({ path: join(rootDir, ".env.local") }); -// Validate DATABASE_URL is set -if (!process.env.DATABASE_URL) { - throw new Error( - "DATABASE_URL is not set. Please check your .env or .env.local file." - ); +/** + * Resolve the libSQL connection settings from the environment. + * + * TURSO_DATABASE_URL + TURSO_AUTH_TOKEN is the production path. DATABASE_URL is + * accepted as an alias so existing deploys can be repointed by changing one + * value, but a `postgres://` URL is rejected outright rather than failing later + * with an opaque protocol error. + */ +export function resolveConnection(env = process.env) { + const url = env.TURSO_DATABASE_URL ?? env.DATABASE_URL ?? env.LIBSQL_URL; + + if (!url) { + throw new Error( + "TURSO_DATABASE_URL is not set. Set it to a libsql:// URL (Turso), " + + "or a file: URL for local development. See .env.example.", + ); + } + + if (/^postgres(ql)?:\/\//i.test(url)) { + throw new Error( + `Refusing to connect: "${url.split("@").pop()}" looks like a Postgres URL. ` + + "MeshHook migrated from Supabase/Postgres to Turso (libSQL). " + + "Set TURSO_DATABASE_URL to a libsql:// or file: URL.", + ); + } + + const authToken = env.TURSO_AUTH_TOKEN ?? env.LIBSQL_AUTH_TOKEN; + + // Remote libsql:// and https:// databases require a token; file: does not. + if (/^(libsql|https?):\/\//i.test(url) && !authToken) { + throw new Error( + "TURSO_AUTH_TOKEN is required for remote libSQL URLs. " + + "Generate one with: turso db tokens create ", + ); + } + + return { url, authToken }; } -const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); -export const db = { - one: async (q, p=[]) => (await pool.query(q, p)).rows[0], - oneOrNone: async (q, p=[]) => (await pool.query(q, p)).rows[0] ?? null, - manyOrNone: async (q, p=[]) => (await pool.query(q, p)).rows, - none: async (q, p=[]) => { await pool.query(q, p); }, - tx: async (fn) => { - const client = await pool.connect(); +/** + * Rewrite Postgres `$1` placeholders to libSQL `?`. + * + * libSQL binds positional `?` in order, so this is only safe when the `$n` are + * already in ascending order with no repeats — which is true of all SQL carried + * over from the Postgres implementation. A repeated or out-of-order `$n` would + * silently bind the wrong value, so it throws instead. + * + * Placeholders inside string literals, quoted identifiers and comments are left + * alone. + */ +export function toLibsqlSql(sql) { + if (!sql.includes("$")) return sql; + + let out = ""; + let expected = 1; + let seenPositional = false; + + for (let i = 0; i < sql.length; i++) { + const ch = sql[i]; + + // Skip over anything where a `$n` would not be a placeholder. + if (ch === "'" || ch === '"' || ch === "`") { + const end = skipQuoted(sql, i, ch); + out += sql.slice(i, end); + i = end - 1; + continue; + } + if (ch === "-" && sql[i + 1] === "-") { + const end = sql.indexOf("\n", i); + const stop = end === -1 ? sql.length : end; + out += sql.slice(i, stop); + i = stop - 1; + continue; + } + if (ch === "/" && sql[i + 1] === "*") { + const end = sql.indexOf("*/", i + 2); + const stop = end === -1 ? sql.length : end + 2; + out += sql.slice(i, stop); + i = stop - 1; + continue; + } + + if (ch === "$" && /[0-9]/.test(sql[i + 1] ?? "")) { + let j = i + 1; + while (j < sql.length && /[0-9]/.test(sql[j])) j++; + const n = Number(sql.slice(i + 1, j)); + + if (n !== expected) { + throw new Error( + `Cannot translate SQL to libSQL: expected $${expected} but found $${n}. ` + + "Positional parameters must appear in ascending order without repeats. " + + "Rewrite the query using `?` and pass the argument twice if needed.", + ); + } + + out += "?"; + expected++; + seenPositional = true; + i = j - 1; + continue; + } + + out += ch; + } + + return seenPositional ? out : sql; +} + +/** Advance past a quoted string/identifier starting at `start`, handling doubled-quote escapes. */ +function skipQuoted(sql, start, quote) { + let i = start + 1; + while (i < sql.length) { + if (sql[i] === quote) { + if (sql[i + 1] === quote) { + i += 2; // escaped quote + continue; + } + return i + 1; + } + i++; + } + return sql.length; +} + +/** + * Coerce JS values into something libSQL can bind. + * + * SQLite has no native boolean, date or JSON type. Postgres accepted these + * directly, so normalise here rather than at every call site. + */ +export function toBindable(value) { + if (value === undefined || value === null) return null; + if (typeof value === "boolean") return value ? 1 : 0; + if (value instanceof Date) return value.toISOString(); + if (value instanceof Uint8Array || value instanceof ArrayBuffer) return value; + if (typeof value === "object") return JSON.stringify(value); + return value; +} + +const bindAll = (params) => (params ?? []).map(toBindable); + +/** + * Parse a JSON/TEXT column back into a value. + * + * Columns that were `jsonb` under Postgres arrive as strings. Values that are + * already objects (or null) pass straight through so this is safe to apply + * unconditionally. + */ +export function json(value, fallback = null) { + if (value === null || value === undefined) return fallback; + if (typeof value === "object") return value; + if (typeof value !== "string") return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +/** ISO-8601 UTC timestamp — the stored representation for every former timestamptz column. */ +export const now = () => new Date().toISOString(); + +/** Row objects from libSQL are null-prototype; give call sites a plain object. */ +const plain = (row) => (row ? { ...row } : row); + +/** + * SQLite allows a single writer at a time. When a second writer arrives it gets + * SQLITE_BUSY immediately rather than queueing, which matters here because + * several workers poll the same queue concurrently and each dequeue is a write + * transaction. + * + * Postgres solved this with row-level locks; the equivalent is to wait and try + * again. Retries use exponential backoff with jitter so competing writers do + * not resynchronise on the same retry instant. + */ +const BUSY_CODES = new Set(["SQLITE_BUSY", "SQLITE_LOCKED", "SQLITE_BUSY_SNAPSHOT"]); + +function isBusy(error) { + const code = error?.code ?? error?.cause?.code; + if (code && BUSY_CODES.has(code)) return true; + // Remote libSQL surfaces contention as a message rather than a code. + return /database is locked|SQLITE_BUSY/i.test(error?.message ?? ""); +} + +/** + * Serialise write transactions within this process. + * + * @libsql/client multiplexes every statement over a single underlying + * connection. Two overlapping `transaction("write")` calls therefore interleave + * on that one connection: the loser gets SQLITE_BUSY on BEGIN, and — worse — + * the winner then fails its COMMIT with "cannot commit transaction - SQL + * statements in progress". Retrying alone livelocks, because each retry + * re-creates the interleaving that breaks the in-flight commit. + * + * SQLite permits one writer at a time regardless, so queueing write + * transactions behind one another costs no real concurrency. Contention with + * *other processes* is still handled by withBusyRetry. + * + * Returns a function that runs `fn` once the previous caller has settled. + */ +function createWriteLock() { + let tail = Promise.resolve(); + + return (fn) => { + // Chain on settlement, not success, so one failed transaction does not + // wedge every later one. + const result = tail.then(fn, fn); + tail = result.then( + () => {}, + () => {}, + ); + return result; + }; +} + +async function withBusyRetry(fn, { attempts = 8, baseDelayMs = 5, maxDelayMs = 250 } = {}) { + let lastError; + + for (let attempt = 0; attempt < attempts; attempt++) { try { - await client.query("begin"); - const tdb = { - one: (q,p=[]) => client.query(q,p).then(r=>r.rows[0]), - none: (q,p=[]) => client.query(q,p).then(()=>{}), + return await fn(); + } catch (error) { + if (!isBusy(error)) throw error; + lastError = error; + const backoff = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt); + await new Promise((r) => setTimeout(r, backoff / 2 + Math.random() * (backoff / 2))); + } + } + + throw lastError; +} + +function wrap(executor) { + return { + /** First row of the result. Throws if the query matched nothing. */ + one: async (q, p = []) => { + const { rows } = await executor(q, p); + if (rows.length === 0) { + throw new Error("Expected exactly one row, got none"); + } + return plain(rows[0]); + }, + /** First row, or null when the query matched nothing. */ + oneOrNone: async (q, p = []) => { + const { rows } = await executor(q, p); + return rows.length ? plain(rows[0]) : null; + }, + /** All matching rows (possibly empty). */ + manyOrNone: async (q, p = []) => { + const { rows } = await executor(q, p); + return rows.map(plain); + }, + /** Run a statement for its side effects; returns rowsAffected/lastInsertRowid. */ + none: async (q, p = []) => { + const res = await executor(q, p); + return { + rowsAffected: res.rowsAffected ?? 0, + lastInsertRowid: res.lastInsertRowid ?? null, }; - const res = await fn(tdb); - await client.query("commit"); - return res; - } catch (e) { - await client.query("rollback"); throw e; - } finally { client.release(); } + }, + }; +} + +let client; + +/** Lazily create the shared libSQL client so importing this module never connects. */ +export function getClient() { + if (!client) { + const { url, authToken } = resolveConnection(); + client = createClient({ url, authToken }); } + return client; +} + +/** Guards every top-level statement and transaction on the shared client. */ +const sharedWriteLock = createWriteLock(); + +// Standalone statements take the lock too, not just transactions. They share +// the one connection, so an unlocked INSERT issued while a transaction is open +// interleaves with it and makes that transaction's COMMIT fail with "SQL +// statements in progress". Statements *inside* a transaction bypass the lock — +// they run on the transaction handle, whose caller already holds it, so there +// is no re-entrancy deadlock. +const execute = (sql, params) => + sharedWriteLock(() => + withBusyRetry(() => getClient().execute({ sql: toLibsqlSql(sql), args: bindAll(params) })), + ); + +export const db = { + ...wrap(execute), + + /** + * Run `fn` inside a transaction, committing on success and rolling back on + * throw. The handle passed to `fn` exposes the full query API — the Postgres + * version only offered one/none, which forced awkward workarounds at a few + * call sites. + */ + tx: (fn) => + // Serialised against other writers in this process, then retried on + // cross-process contention. The whole transaction is retried, not just the + // failing statement — a partial transaction is rolled back first, so `fn` + // must be safe to run more than once. + sharedWriteLock(() => + withBusyRetry(async () => { + const trx = await getClient().transaction("write"); + try { + const tdb = wrap((sql, params) => + trx.execute({ sql: toLibsqlSql(sql), args: bindAll(params) }), + ); + const res = await fn(tdb); + await trx.commit(); + return res; + } catch (e) { + // A transaction already closed by a failed commit cannot be rolled back. + try { + await trx.rollback(); + } catch { + /* already closed */ + } + throw e; + } + }), + ), + + /** + * Execute several statements atomically. Thin wrapper over the libSQL batch + * API, used by the migration runner. + */ + batch: async (statements) => + getClient().batch( + statements.map((s) => + typeof s === "string" + ? { sql: toLibsqlSql(s), args: [] } + : { sql: toLibsqlSql(s.sql), args: bindAll(s.args) }, + ), + "write", + ), + + /** Close the underlying connection. Mainly for tests and one-shot scripts. */ + close: async () => { + if (client) { + client.close(); + client = undefined; + } + }, }; + +/** + * Build an isolated db handle against an explicit URL, bypassing the shared + * client. Tests use this for throwaway databases. + * + * Do not pass a bare ":memory:" — @libsql/client opens a fresh, empty in-memory + * database for each connection it makes, so a table created by one statement is + * invisible to the next and every transaction starts blank. Use a temporary + * file (see createTestDb in src/queue/test-helpers.js), or + * "file::memory:?cache=shared" if a single process-wide database is genuinely + * what you want. + */ +export function createDb({ url, authToken } = {}) { + if (!url) { + throw new Error("createDb requires a url (e.g. file:/tmp/test.db)"); + } + if (url === ":memory:") { + throw new Error( + 'createDb cannot use ":memory:" — @libsql/client gives each connection its own ' + + 'empty database. Use a temp file, or "file::memory:?cache=shared".', + ); + } + + const local = createClient({ url, authToken }); + // Each handle gets its own lock, matching its own connection. + const writeLock = createWriteLock(); + + const exec = (sql, params) => + writeLock(() => + withBusyRetry(() => local.execute({ sql: toLibsqlSql(sql), args: bindAll(params) })), + ); + + return { + ...wrap(exec), + tx: (fn) => + writeLock(() => + withBusyRetry(async () => { + const trx = await local.transaction("write"); + try { + const res = await fn( + wrap((sql, params) => + trx.execute({ sql: toLibsqlSql(sql), args: bindAll(params) }), + ), + ); + await trx.commit(); + return res; + } catch (e) { + try { + await trx.rollback(); + } catch { + /* already closed */ + } + throw e; + } + }), + ), + batch: async (statements) => + local.batch( + statements.map((s) => + typeof s === "string" + ? { sql: toLibsqlSql(s), args: [] } + : { sql: toLibsqlSql(s.sql), args: bindAll(s.args) }, + ), + "write", + ), + close: async () => local.close(), + }; +} diff --git a/packages/shared/lib/queue.js b/packages/shared/lib/queue.js index 9f205db..3f99263 100644 --- a/packages/shared/lib/queue.js +++ b/packages/shared/lib/queue.js @@ -1,17 +1,279 @@ -import { EventEmitter } from "node:events"; -const bus = new EventEmitter(); +/** + * SQLite-backed message queue. + * + * Reimplements the subset of pgmq that MeshHook used. pgmq is a Postgres + * extension with no SQLite equivalent, and the old code reached it through + * Supabase RPC calls (`client.rpc('pgmq_send', …)`), so both the transport and + * the implementation had to be replaced. + * + * Semantics carried over unchanged: + * - send() appends a message, optionally invisible until a delay elapses. + * - read() leases the oldest visible message: it pushes `vt` forward by the + * visibility timeout and increments `read_ct`. The message stays in the + * table, so a consumer that dies without acknowledging simply loses its + * lease and the message is redelivered. + * - deleteMessage() removes it permanently; archive() moves it aside. + * + * The claim must be atomic — two workers polling concurrently must not lease + * the same message. pgmq guaranteed this with FOR UPDATE SKIP LOCKED. SQLite + * has no row locks, but a write transaction holds an exclusive lock on the + * whole database, which gives the same guarantee here. + */ -export const queue = { - async process(topic, handler) { - bus.on(topic, async (data) => { - try { await handler({ data }); } catch (e) { console.error(e); } +import { db as sharedDb, json } from "./db.js"; + +/** ISO-8601 UTC, matching the format every timestamp column stores. */ +const iso = (date) => date.toISOString(); + +/** A timestamp `seconds` in the future (or the past, for negative values). */ +const offsetIso = (seconds) => iso(new Date(Date.now() + seconds * 1000)); + +export class Queue { + /** + * @param {object} options + * @param {string} [options.name] Queue name; rows are partitioned by this column. + * @param {object} [options.db] Database handle, for tests wanting isolation. + */ + constructor({ name = "workflow_jobs", db = sharedDb } = {}) { + if (!name) throw new Error("Queue name is required"); + this.name = name; + this.db = db; + } + + /** + * Append a message. + * + * @param {object} message Arbitrary JSON-serialisable payload. + * @param {number} delaySeconds Seconds to keep the message invisible. + * @returns {Promise} The new msg_id. + */ + async send(message, delaySeconds = 0) { + if (message === null || typeof message !== "object") { + throw new Error("Queue message must be an object"); + } + + const { lastInsertRowid } = await this.db.none( + `insert into queue_messages (queue_name, message, vt) values (?, ?, ?)`, + [this.name, JSON.stringify(message), offsetIso(delaySeconds)], + ); + + // libSQL returns lastInsertRowid as a BigInt; msg_id is used as a plain + // number everywhere else (JSON payloads, tracking rows), so narrow it here. + return Number(lastInsertRowid); + } + + /** + * Lease up to `qty` visible messages. + * + * @param {number} vtSeconds Visibility timeout for the lease. + * @param {number} qty Maximum messages to return. + * @returns {Promise>} + */ + async read(vtSeconds = 30, qty = 1) { + return this.db.tx(async (t) => { + const nowIso = iso(new Date()); + + const candidates = await t.manyOrNone( + `select msg_id, message, read_ct, enqueued_at + from queue_messages + where queue_name = ? and vt <= ? + order by msg_id + limit ?`, + [this.name, nowIso, qty], + ); + + if (candidates.length === 0) return []; + + const newVt = offsetIso(vtSeconds); + const ids = candidates.map((c) => c.msg_id); + + // Placeholders are built from the row count, never from user input. + await t.none( + `update queue_messages + set vt = ?, read_ct = read_ct + 1 + where msg_id in (${ids.map(() => "?").join(",")})`, + [newVt, ...ids], + ); + + return candidates.map((c) => ({ + msg_id: Number(c.msg_id), + message: json(c.message, {}), + read_ct: c.read_ct + 1, + enqueued_at: c.enqueued_at, + vt: newVt, + })); + }); + } + + /** + * Inspect messages without leasing them. + * + * The old DLQ code called pgmq_read with vt=0 to browse the queue, which + * still incremented read_ct and briefly hid each message. Inspection should + * not mutate the queue, so browsing goes through this instead. + * + * @param {number} limit Maximum messages to return. + * @returns {Promise>} + */ + async peek(limit = 100) { + const rows = await this.db.manyOrNone( + `select msg_id, message, read_ct, enqueued_at, vt + from queue_messages + where queue_name = ? + order by msg_id + limit ?`, + [this.name, limit], + ); + + return rows.map((r) => ({ + msg_id: Number(r.msg_id), + message: json(r.message, {}), + read_ct: r.read_ct, + enqueued_at: r.enqueued_at, + vt: r.vt, + })); + } + + /** Fetch one message by id without leasing it. */ + async peekOne(msgId) { + const row = await this.db.oneOrNone( + `select msg_id, message, read_ct, enqueued_at, vt + from queue_messages where queue_name = ? and msg_id = ?`, + [this.name, msgId], + ); + if (!row) return null; + return { + msg_id: Number(row.msg_id), + message: json(row.message, {}), + read_ct: row.read_ct, + enqueued_at: row.enqueued_at, + vt: row.vt, + }; + } + + /** + * Remove a message permanently. + * @returns {Promise} True when a row was deleted. + */ + async deleteMessage(msgId) { + const { rowsAffected } = await this.db.none( + `delete from queue_messages where queue_name = ? and msg_id = ?`, + [this.name, msgId], + ); + return rowsAffected > 0; + } + + /** + * Move a message to the archive table, preserving its msg_id. + * @returns {Promise} True when a row was archived. + */ + async archive(msgId) { + return this.db.tx(async (t) => { + const row = await t.oneOrNone( + `select msg_id, queue_name, message, read_ct, enqueued_at + from queue_messages where queue_name = ? and msg_id = ?`, + [this.name, msgId], + ); + if (!row) return false; + + await t.none( + `insert into queue_archive (msg_id, queue_name, message, read_ct, enqueued_at) + values (?, ?, ?, ?, ?) + on conflict (msg_id) do nothing`, + [row.msg_id, row.queue_name, row.message, row.read_ct, row.enqueued_at], + ); + await t.none(`delete from queue_messages where msg_id = ?`, [msgId]); + return true; }); } -}; -export async function enqueueRun(runId) { - bus.emit("run:orchestrate", { run_id: runId }); + /** + * Make a leased message visible again, optionally after a delay. pgmq called + * this set_vt; it is how the worker returns a job for retry without waiting + * for the lease to lapse naturally. + */ + async setVisibilityTimeout(msgId, seconds) { + const { rowsAffected } = await this.db.none( + `update queue_messages set vt = ? where queue_name = ? and msg_id = ?`, + [offsetIso(seconds), this.name, msgId], + ); + return rowsAffected > 0; + } + + /** + * Delete every message on this queue. + * @returns {Promise} Messages removed. + */ + async purge() { + const { rowsAffected } = await this.db.none( + `delete from queue_messages where queue_name = ?`, + [this.name], + ); + return rowsAffected; + } + + /** + * Depth and age of the queue. Only messages that are currently visible count + * toward queue_length, matching how pgmq's metrics view reported backlog. + */ + async metrics() { + const nowIso = iso(new Date()); + const row = await this.db.oneOrNone( + `select + count(*) as queue_length, + min(enqueued_at) as oldest, + max(enqueued_at) as newest + from queue_messages + where queue_name = ? and vt <= ?`, + [this.name, nowIso], + ); + + const ageSeconds = (ts) => + ts ? Math.max(0, Math.round((Date.now() - Date.parse(ts)) / 1000)) : null; + + return { + queue_name: this.name, + queue_length: Number(row?.queue_length ?? 0), + oldest_msg_age_seconds: ageSeconds(row?.oldest), + newest_msg_age_seconds: ageSeconds(row?.newest), + }; + } + + /** Total messages including those currently leased — useful for tests. */ + async size() { + const row = await this.db.oneOrNone( + `select count(*) as n from queue_messages where queue_name = ?`, + [this.name], + ); + return Number(row?.n ?? 0); + } +} + +export function createQueue(options) { + return new Queue(options); } -export async function enqueueStep(runId, node) { - bus.emit("step:execute", { run_id: runId, node }); + +/** + * Read the tunables for a queue from queue_config, falling back to the same + * defaults the table declares if no row exists. + */ +export async function getQueueConfig(queueName, db = sharedDb) { + const row = await db.oneOrNone( + `select visibility_timeout_seconds, max_retry_attempts, retry_backoff_base_ms, + retry_backoff_max_ms, dlq_enabled + from queue_config where queue_name = ?`, + [queueName], + ); + + if (!row) { + return { + visibility_timeout_seconds: 30, + max_retry_attempts: 5, + retry_backoff_base_ms: 1000, + retry_backoff_max_ms: 300000, + dlq_enabled: true, + }; + } + + return { ...row, dlq_enabled: row.dlq_enabled === 1 }; } diff --git a/packages/shared/package.json b/packages/shared/package.json index 764db90..de55dbd 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -4,10 +4,13 @@ "type": "module", "exports": { "./lib/db.js": "./lib/db.js", - "./lib/queue.js": "./lib/queue.js" + "./lib/queue.js": "./lib/queue.js", + "./lib/auth.js": "./lib/auth.js", + "./lib/authz.js": "./lib/authz.js", + "./lib/crypto.js": "./lib/crypto.js" }, "dependencies": { - "dotenv": "^17.2.3", - "pg": "^8.16.3" + "@libsql/client": "^0.15.7", + "dotenv": "^17.2.3" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..ff9803e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3692 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@meshhook/shared': + specifier: workspace:* + version: link:packages/shared + fast-xml-parser: + specifier: ^5.3.0 + version: 5.10.1 + jmespath: + specifier: ^0.16.0 + version: 0.16.0 + openai: + specifier: ^6.3.0 + version: 6.49.0(ws@8.21.3) + devDependencies: + chai: + specifier: ^5.1.2 + version: 5.3.3 + dotenv: + specifier: ^17.2.3 + version: 17.4.2 + inquirer: + specifier: ^12.9.6 + version: 12.11.1(@types/node@26.2.0) + prettier: + specifier: ^3.3.3 + version: 3.9.6 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@26.2.0)(@vitest/ui@3.2.7)(jsdom@27.4.0(supports-color@7.2.0))(supports-color@7.2.0) + + apps/web: + dependencies: + '@libsql/client': + specifier: ^0.15.7 + version: 0.15.15 + '@meshhook/shared': + specifier: workspace:* + version: link:../../packages/shared + '@xyflow/svelte': + specifier: ^1.3.1 + version: 1.6.2(svelte@5.56.8) + jmespath: + specifier: ^0.16.0 + version: 0.16.0 + undici: + specifier: ^6.19.8 + version: 6.28.0 + devDependencies: + '@eslint/js': + specifier: ^9.15.0 + version: 9.39.5 + '@sveltejs/adapter-auto': + specifier: ^3.2.5 + version: 3.3.1(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)))(svelte@5.56.8)(typescript@6.0.3)(vite@5.4.21(@types/node@26.2.0))) + '@sveltejs/adapter-node': + specifier: ^5.2.9 + version: 5.5.7(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)))(svelte@5.56.8)(typescript@6.0.3)(vite@5.4.21(@types/node@26.2.0))) + '@sveltejs/kit': + specifier: ^2.7.7 + version: 2.70.2(@sveltejs/vite-plugin-svelte@4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)))(svelte@5.56.8)(typescript@6.0.3)(vite@5.4.21(@types/node@26.2.0)) + '@sveltejs/vite-plugin-svelte': + specifier: ^4.0.1 + version: 4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)) + '@vitest/ui': + specifier: ^3.2.4 + version: 3.2.7(vitest@3.2.7) + eslint: + specifier: ^9.15.0 + version: 9.39.5(supports-color@7.2.0) + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.2(eslint@9.39.5(supports-color@7.2.0)) + eslint-plugin-svelte: + specifier: ^2.46.1 + version: 2.46.1(eslint@9.39.5(supports-color@7.2.0))(svelte@5.56.8) + globals: + specifier: ^15.12.0 + version: 15.15.0 + jsdom: + specifier: ^27.0.0 + version: 27.4.0(supports-color@7.2.0) + prettier: + specifier: ^3.3.3 + version: 3.9.6 + prettier-plugin-svelte: + specifier: ^3.2.8 + version: 3.5.2(prettier@3.9.6)(svelte@5.56.8) + svelte: + specifier: ^5.2.7 + version: 5.56.8 + svelte-check: + specifier: ^4.0.8 + version: 4.7.5(picomatch@4.0.5)(svelte@5.56.8)(typescript@6.0.3) + vite: + specifier: ^5.4.11 + version: 5.4.21(@types/node@26.2.0) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@26.2.0)(@vitest/ui@3.2.7)(jsdom@27.4.0(supports-color@7.2.0))(supports-color@7.2.0) + + packages/cli: + dependencies: + kleur: + specifier: ^4.1.5 + version: 4.1.5 + minimist: + specifier: ^1.2.8 + version: 1.2.8 + + packages/shared: + dependencies: + '@libsql/client': + specifier: ^0.15.7 + version: 0.15.15 + dotenv: + specifier: ^17.2.3 + version: 17.4.2 + + workers: + dependencies: + '@meshhook/shared': + specifier: workspace:* + version: link:../packages/shared + undici: + specifier: ^6.19.8 + version: 6.28.0 + +packages: + + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@4.1.2': + resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@libsql/client@0.15.15': + resolution: {integrity: sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==} + + '@libsql/core@0.15.15': + resolution: {integrity: sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA==} + + '@libsql/darwin-arm64@0.5.29': + resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.29': + resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.7.0': + resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==} + + '@libsql/isomorphic-fetch@0.3.1': + resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==} + engines: {node: '>=18.0.0'} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm-gnueabihf@0.5.29': + resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm-musleabihf@0.5.29': + resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm64-gnu@0.5.29': + resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.29': + resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.29': + resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.29': + resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.29': + resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/plugin-commonjs@29.0.3': + resolution: {integrity: sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-json@6.1.0': + resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@6.0.3': + resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@svelte-put/shortcut@4.2.0': + resolution: {integrity: sha512-hqNLo4yEc++SLgAkZUvuwMxIAsii9qjQtTuzfcYVf3xRxa+0HFcfaWFK7LdU3l+15s9SYVNbPB0qQj9CHFqSuw==} + peerDependencies: + svelte: ^5.1.0 + + '@sveltejs/acorn-typescript@1.0.12': + resolution: {integrity: sha512-J1jNYG23QWd67UfrQSFHtjhV37r9mVi0gdc12A3MWPldOjRK35Xk+um+qACPVjgw3AleiqoyEAhok8Wm3q46NA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-auto@3.3.1': + resolution: {integrity: sha512-5Sc7WAxYdL6q9j/+D0jJKjGREGlfIevDyHSQ2eNETHcB1TKlQWHcAo8AS8H1QdjNvSXpvOwNjykDUHPEAyGgdQ==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/adapter-node@5.5.7': + resolution: {integrity: sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==} + peerDependencies: + '@sveltejs/kit': ^2.4.0 + + '@sveltejs/kit@2.70.2': + resolution: {integrity: sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 || ^6.0.0 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/load-config@0.2.2': + resolution: {integrity: sha512-K7dsJDQxBOF+f+epuhMactcjK2VP4MRkLKtwSykNtEI+cKVEyzrmmhQ1pmoxI800m4JKcyJ05L2M4yPwAGiBNw==} + engines: {node: '>= 18.0.0'} + + '@sveltejs/vite-plugin-svelte-inspector@3.0.1': + resolution: {integrity: sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^4.0.0-next.0||^4.0.0 + svelte: ^5.0.0-next.96 || ^5.0.0 + vite: ^5.0.0 + + '@sveltejs/vite-plugin-svelte@4.0.4': + resolution: {integrity: sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0-next.96 || ^5.0.0 + vite: ^5.0.0 + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/ui@3.2.7': + resolution: {integrity: sha512-eVtcpJXGhS0GjMuHROfbXLhlxooyUcuip8GNzzjDD5jzafZqzanJH4W3VGmUxHNx4fv6qQGUGJHRpGUdj+9D6Q==} + peerDependencies: + vitest: 3.2.7 + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + '@xyflow/svelte@1.6.2': + resolution: {integrity: sha512-wImcz0c4mCa2SYxo5p1YSBqdTBqI4Ky2CYO6XZfNqMIxVr6mB2cdVaduJqHuqTETQuUKv4Gr0eh3Ev3iU8k8fg==} + peerDependencies: + svelte: ^5.25.0 + + '@xyflow/system@0.0.79': + resolution: {integrity: sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@6.0.1: + resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} + engines: {node: '>=20'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + + devalue@5.9.0: + resolution: {integrity: sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-prettier@9.1.2: + resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-svelte@2.46.1: + resolution: {integrity: sha512-7xYr2o4NID/f9OEYMqxsEQsCsj4KaMy4q5sANaKkAb6/QeCjYFxRmDm2S3YC3A3pl1kyPZ/syOx/i7LcWYSbIw==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0-0 || ^9.0.0-0 + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrap@2.3.2: + resolution: {integrity: sha512-40GyiEJevYKXzYTHtZkFqAgTjLOuFcaXMao8TPyOlnWTlkHDlvZ6mPMJaJyOqVwrVCgomEG1WhJd81w0X+IcCw==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-xml-builder@1.3.0: + resolution: {integrity: sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==} + + fast-xml-parser@5.10.1: + resolution: {integrity: sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inquirer@12.11.1: + resolution: {integrity: sha512-9VF7mrY+3OmsAfjH3yKz/pLbJ5z22E23hENKw3/LNSaA/sAt3v49bDRY+Ygct1xwuKT+U+cBfTzjCPySna69Qw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + is-unsafe@2.0.0: + resolution: {integrity: sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jmespath@0.16.0: + resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} + engines: {node: '>= 0.6.0'} + + js-base64@3.9.2: + resolution: {integrity: sha512-6zayE8QlUdiweYI6cETD/XBSqFcoCUlufn/29PJR99r82x1yDnIprRca0YvAYpAW+ez0GuQkVBC6xG5QkD7OjA==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + known-css-properties@0.35.0: + resolution: {integrity: sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + libsql@0.5.29: + resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + openai@6.49.0: + resolution: {integrity: sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + ws: + optional: true + zod: + optional: true + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss-load-config@3.1.4: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-safe-parser@6.0.0: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-svelte@3.5.2: + resolution: {integrity: sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==} + peerDependencies: + prettier: ^3.0.0 + svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-async@4.0.6: + resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} + engines: {node: '>=0.12.0'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + strnum@2.4.1: + resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svelte-check@4.7.5: + resolution: {integrity: sha512-NnkHGCTPH6k4ka1E9IpTuNv40uLArHnX52kLEuaHSGqRlPYTnkbFs529jSWG+y+wDy+v+jA2PQ0soN1umVK+OA==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.0.0 || ^6.0.0 + + svelte-eslint-parser@0.43.0: + resolution: {integrity: sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + + svelte@5.56.8: + resolution: {integrity: sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==} + engines: {node: '>=18'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + hasBin: true + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@acemir/cssom@0.9.31': {} + + '@asamuzakjp/css-color@4.1.2': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(supports-color@7.2.0))': + dependencies: + eslint: 9.39.5(supports-color@7.2.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2(supports-color@7.2.0)': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6(supports-color@7.2.0)': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@7.2.0) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@exodus/bytes@1.15.1': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@26.2.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@26.2.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.2.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/confirm@5.1.21(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.2.0) + '@inquirer/type': 3.0.10(@types/node@26.2.0) + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/core@10.3.2(@types/node@26.2.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.2.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/editor@4.2.23(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.2.0) + '@inquirer/external-editor': 1.0.3(@types/node@26.2.0) + '@inquirer/type': 3.0.10(@types/node@26.2.0) + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/expand@4.0.23(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.2.0) + '@inquirer/type': 3.0.10(@types/node@26.2.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/external-editor@1.0.3(@types/node@26.2.0)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.2.0) + '@inquirer/type': 3.0.10(@types/node@26.2.0) + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/number@3.0.23(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.2.0) + '@inquirer/type': 3.0.10(@types/node@26.2.0) + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/password@4.0.23(@types/node@26.2.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@26.2.0) + '@inquirer/type': 3.0.10(@types/node@26.2.0) + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/prompts@7.10.1(@types/node@26.2.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@26.2.0) + '@inquirer/confirm': 5.1.21(@types/node@26.2.0) + '@inquirer/editor': 4.2.23(@types/node@26.2.0) + '@inquirer/expand': 4.0.23(@types/node@26.2.0) + '@inquirer/input': 4.3.1(@types/node@26.2.0) + '@inquirer/number': 3.0.23(@types/node@26.2.0) + '@inquirer/password': 4.0.23(@types/node@26.2.0) + '@inquirer/rawlist': 4.1.11(@types/node@26.2.0) + '@inquirer/search': 3.2.2(@types/node@26.2.0) + '@inquirer/select': 4.4.2(@types/node@26.2.0) + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/rawlist@4.1.11(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.2.0) + '@inquirer/type': 3.0.10(@types/node@26.2.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/search@3.2.2(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.2.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.2.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/select@4.4.2(@types/node@26.2.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@26.2.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.2.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/type@3.0.10(@types/node@26.2.0)': + optionalDependencies: + '@types/node': 26.2.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@libsql/client@0.15.15': + dependencies: + '@libsql/core': 0.15.15 + '@libsql/hrana-client': 0.7.0 + js-base64: 3.9.2 + libsql: 0.5.29 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/core@0.15.15': + dependencies: + js-base64: 3.9.2 + + '@libsql/darwin-arm64@0.5.29': + optional: true + + '@libsql/darwin-x64@0.5.29': + optional: true + + '@libsql/hrana-client@0.7.0': + dependencies: + '@libsql/isomorphic-fetch': 0.3.1 + '@libsql/isomorphic-ws': 0.1.5 + js-base64: 3.9.2 + node-fetch: 3.3.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/isomorphic-fetch@0.3.1': {} + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.1 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm-gnueabihf@0.5.29': + optional: true + + '@libsql/linux-arm-musleabihf@0.5.29': + optional: true + + '@libsql/linux-arm64-gnu@0.5.29': + optional: true + + '@libsql/linux-arm64-musl@0.5.29': + optional: true + + '@libsql/linux-x64-gnu@0.5.29': + optional: true + + '@libsql/linux-x64-musl@0.5.29': + optional: true + + '@libsql/win32-x64-msvc@0.5.29': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@neon-rs/load@0.0.4': {} + + '@nodable/entities@3.0.0': {} + + '@polka/url@1.0.0-next.29': {} + + '@rollup/plugin-commonjs@29.0.3(rollup@4.62.4)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@4.62.4) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.5.0(picomatch@4.0.5) + is-reference: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.5 + optionalDependencies: + rollup: 4.62.4 + + '@rollup/plugin-json@6.1.0(rollup@4.62.4)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@4.62.4) + optionalDependencies: + rollup: 4.62.4 + + '@rollup/plugin-node-resolve@16.0.3(rollup@4.62.4)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@4.62.4) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.12 + optionalDependencies: + rollup: 4.62.4 + + '@rollup/plugin-replace@6.0.3(rollup@4.62.4)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@4.62.4) + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.62.4 + + '@rollup/pluginutils@5.4.0(rollup@4.62.4)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.5 + optionalDependencies: + rollup: 4.62.4 + + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@svelte-put/shortcut@4.2.0(svelte@5.56.8)': + dependencies: + svelte: 5.56.8 + + '@sveltejs/acorn-typescript@1.0.12(acorn@8.18.0)': + dependencies: + acorn: 8.18.0 + + '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)))(svelte@5.56.8)(typescript@6.0.3)(vite@5.4.21(@types/node@26.2.0)))': + dependencies: + '@sveltejs/kit': 2.70.2(@sveltejs/vite-plugin-svelte@4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)))(svelte@5.56.8)(typescript@6.0.3)(vite@5.4.21(@types/node@26.2.0)) + import-meta-resolve: 4.2.0 + + '@sveltejs/adapter-node@5.5.7(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)))(svelte@5.56.8)(typescript@6.0.3)(vite@5.4.21(@types/node@26.2.0)))': + dependencies: + '@rollup/plugin-commonjs': 29.0.3(rollup@4.62.4) + '@rollup/plugin-json': 6.1.0(rollup@4.62.4) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.4) + '@rollup/plugin-replace': 6.0.3(rollup@4.62.4) + '@sveltejs/kit': 2.70.2(@sveltejs/vite-plugin-svelte@4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)))(svelte@5.56.8)(typescript@6.0.3)(vite@5.4.21(@types/node@26.2.0)) + rollup: 4.62.4 + + '@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)))(svelte@5.56.8)(typescript@6.0.3)(vite@5.4.21(@types/node@26.2.0))': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.12(acorn@8.18.0) + '@sveltejs/vite-plugin-svelte': 4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)) + '@types/cookie': 0.6.0 + acorn: 8.18.0 + cookie: 0.6.0 + devalue: 5.9.0 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.1.2 + sirv: 3.0.2 + svelte: 5.56.8 + vite: 5.4.21(@types/node@26.2.0) + optionalDependencies: + typescript: 6.0.3 + + '@sveltejs/load-config@0.2.2': {} + + '@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)))(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0))': + dependencies: + '@sveltejs/vite-plugin-svelte': 4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)) + debug: 4.4.3(supports-color@7.2.0) + svelte: 5.56.8 + vite: 5.4.21(@types/node@26.2.0) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)))(supports-color@7.2.0)(svelte@5.56.8)(vite@5.4.21(@types/node@26.2.0)) + debug: 4.4.3(supports-color@7.2.0) + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.56.8 + vite: 5.4.21(@types/node@26.2.0) + vitefu: 1.1.3(vite@5.4.21(@types/node@26.2.0)) + transitivePeerDependencies: + - supports-color + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/cookie@0.6.0': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@types/resolve@1.20.2': {} + + '@types/trusted-types@2.0.7': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.2.0 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@5.4.21(@types/node@26.2.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@26.2.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/ui@3.2.7(vitest@3.2.7)': + dependencies: + '@vitest/utils': 3.2.7 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.17 + tinyrainbow: 2.0.0 + vitest: 3.2.7(@types/node@26.2.0)(@vitest/ui@3.2.7)(jsdom@27.4.0(supports-color@7.2.0))(supports-color@7.2.0) + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@xyflow/svelte@1.6.2(svelte@5.56.8)': + dependencies: + '@svelte-put/shortcut': 4.2.0(svelte@5.56.8) + '@xyflow/system': 0.0.79 + svelte: 5.56.8 + + '@xyflow/system@0.0.79': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + anynum@1.0.1: {} + + argparse@2.0.1: {} + + aria-query@5.3.1: {} + + assertion-error@2.0.1: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + cac@6.7.14: {} + + callsites@3.1.0: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chardet@2.2.0: {} + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + cli-width@4.1.0: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commondir@1.0.1: {} + + concat-map@0.0.1: {} + + cookie@0.6.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.5.2 + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + data-uri-to-buffer@4.0.1: {} + + data-urls@6.0.1: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 15.1.0 + + debug@4.4.3(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + detect-libc@2.0.2: {} + + devalue@5.9.0: {} + + dotenv@17.4.2: {} + + emoji-regex@8.0.0: {} + + entities@8.0.0: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escape-string-regexp@4.0.0: {} + + eslint-compat-utils@0.5.1(eslint@9.39.5(supports-color@7.2.0)): + dependencies: + eslint: 9.39.5(supports-color@7.2.0) + semver: 7.8.5 + + eslint-config-prettier@9.1.2(eslint@9.39.5(supports-color@7.2.0)): + dependencies: + eslint: 9.39.5(supports-color@7.2.0) + + eslint-plugin-svelte@2.46.1(eslint@9.39.5(supports-color@7.2.0))(svelte@5.56.8): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@7.2.0)) + '@jridgewell/sourcemap-codec': 1.5.5 + eslint: 9.39.5(supports-color@7.2.0) + eslint-compat-utils: 0.5.1(eslint@9.39.5(supports-color@7.2.0)) + esutils: 2.0.3 + known-css-properties: 0.35.0 + postcss: 8.5.26 + postcss-load-config: 3.1.4(postcss@8.5.26) + postcss-safe-parser: 6.0.0(postcss@8.5.26) + postcss-selector-parser: 6.1.4 + semver: 7.8.5 + svelte-eslint-parser: 0.43.0(svelte@5.56.8) + optionalDependencies: + svelte: 5.56.8 + transitivePeerDependencies: + - ts-node + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.5(supports-color@7.2.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@7.2.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2(supports-color@7.2.0) + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6(supports-color@7.2.0) + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@7.2.0) + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + esm-env@1.2.2: {} + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + espree@9.6.1: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrap@2.3.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-xml-builder@1.3.0: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.10.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.0 + is-unsafe: 2.0.0 + path-expression-matcher: 1.6.2 + strnum: 2.4.1 + xml-naming: 0.3.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + fflate@0.8.3: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@15.15.0: {} + + has-flag@4.0.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + http-proxy-agent@7.0.2(supports-color@7.2.0): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6(supports-color@7.2.0): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + inquirer@12.11.1(@types/node@26.2.0): + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@26.2.0) + '@inquirer/prompts': 7.10.1(@types/node@26.2.0) + '@inquirer/type': 3.0.10(@types/node@26.2.0) + mute-stream: 2.0.0 + run-async: 4.0.6 + rxjs: 7.8.2 + optionalDependencies: + '@types/node': 26.2.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-module@1.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.9 + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + is-unsafe@2.0.0: {} + + isexe@2.0.0: {} + + jmespath@0.16.0: {} + + js-base64@3.9.2: {} + + js-tokens@9.0.1: {} + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + jsdom@27.4.0(supports-color@7.2.0): + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@exodus/bytes': 1.15.1 + cssstyle: 5.3.7 + data-urls: 6.0.1 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - bufferutil + - supports-color + - utf-8-validate + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@4.1.5: {} + + known-css-properties@0.35.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + libsql@0.5.29: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.29 + '@libsql/darwin-x64': 0.5.29 + '@libsql/linux-arm-gnueabihf': 0.5.29 + '@libsql/linux-arm-musleabihf': 0.5.29 + '@libsql/linux-arm64-gnu': 0.5.29 + '@libsql/linux-arm64-musl': 0.5.29 + '@libsql/linux-x64-gnu': 0.5.29 + '@libsql/linux-x64-musl': 0.5.29 + '@libsql/win32-x64-msvc': 0.5.29 + + lilconfig@2.1.0: {} + + locate-character@3.0.0: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loupe@3.2.1: {} + + lru-cache@11.5.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mdn-data@2.27.1: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimist@1.2.8: {} + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + mute-stream@2.0.0: {} + + nanoid@3.3.18: {} + + natural-compare@1.4.0: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + openai@6.49.0(ws@8.21.3): + optionalDependencies: + ws: 8.21.3 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + path-exists@4.0.0: {} + + path-expression-matcher@1.6.2: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss-load-config@3.1.4(postcss@8.5.26): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.3 + optionalDependencies: + postcss: 8.5.26 + + postcss-safe-parser@6.0.0(postcss@8.5.26): + dependencies: + postcss: 8.5.26 + + postcss-scss@4.0.9(postcss@8.5.26): + dependencies: + postcss: 8.5.26 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-svelte@3.5.2(prettier@3.9.6)(svelte@5.56.8): + dependencies: + prettier: 3.9.6 + svelte: 5.56.8 + + prettier@3.9.6: {} + + promise-limit@2.7.0: {} + + punycode@2.3.1: {} + + readdirp@4.1.2: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + + run-async@4.0.6: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@7.8.5: {} + + set-cookie-parser@3.1.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + strnum@2.4.1: + dependencies: + anynum: 1.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svelte-check@4.7.5(picomatch@4.0.5)(svelte@5.56.8)(typescript@6.0.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@sveltejs/load-config': 0.2.2 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.56.8 + typescript: 6.0.3 + transitivePeerDependencies: + - picomatch + + svelte-eslint-parser@0.43.0(svelte@5.56.8): + dependencies: + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + postcss: 8.5.26 + postcss-scss: 4.0.9(postcss@8.5.26) + optionalDependencies: + svelte: 5.56.8 + + svelte@5.56.8: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.12(acorn@8.18.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.18.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.9.0 + esm-env: 1.2.2 + esrap: 2.3.2 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@7.4.10: {} + + tldts@7.4.10: + dependencies: + tldts-core: 7.4.10 + + totalist@3.0.1: {} + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.10 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript@6.0.3: {} + + undici-types@8.3.0: {} + + undici@6.28.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite-node@3.2.4(@types/node@26.2.0)(supports-color@7.2.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@7.2.0) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 5.4.21(@types/node@26.2.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@26.2.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.26 + rollup: 4.62.4 + optionalDependencies: + '@types/node': 26.2.0 + fsevents: 2.3.3 + + vitefu@1.1.3(vite@5.4.21(@types/node@26.2.0)): + optionalDependencies: + vite: 5.4.21(@types/node@26.2.0) + + vitest@3.2.7(@types/node@26.2.0)(@vitest/ui@3.2.7)(jsdom@27.4.0(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@5.4.21(@types/node@26.2.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3(supports-color@7.2.0) + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 5.4.21(@types/node@26.2.0) + vite-node: 3.2.4(@types/node@26.2.0)(supports-color@7.2.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.2.0 + '@vitest/ui': 3.2.7(vitest@3.2.7) + jsdom: 27.4.0(supports-color@7.2.0) + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@4.0.0: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + ws@8.21.3: {} + + xml-name-validator@5.0.0: {} + + xml-naming@0.3.0: {} + + xmlchars@2.2.0: {} + + yaml@1.10.3: {} + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + zimmerframe@1.1.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 067b233..deafbff 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,12 @@ packages: - apps/* - workers - packages/* + +# pnpm 10+ refuses to run a dependency's install/build scripts unless it is +# approved here, and exits non-zero when any are ignored. That failure takes CI +# and Railway builds with it, so every package needing a build script must be +# listed explicitly. +# +# esbuild links the platform binary Vite uses to bundle the SvelteKit app. +allowBuilds: + esbuild: true diff --git a/railway.toml b/railway.toml index f5384a2..98e8ebc 100644 --- a/railway.toml +++ b/railway.toml @@ -1,8 +1,14 @@ # Railway configuration for the Orchestrator Worker +# +# Railpack picks the package manager from the `packageManager` field in +# package.json and from the committed pnpm-lock.yaml. Without those it fell back +# to npm and ran `npm install` during its own install step — before this +# buildCommand ever executed — which fails because the workspace uses pnpm's +# `workspace:*` protocol. Keep both in place or the build breaks again. [build] -buildCommand = "npm install -g pnpm && pnpm install" +buildCommand = "pnpm install --frozen-lockfile" [deploy] startCommand = "node workers/orchestrator.mjs" restartPolicyType = "ON_FAILURE" -restartPolicyMaxRetries = 10 \ No newline at end of file +restartPolicyMaxRetries = 10 diff --git a/railway.web.toml b/railway.web.toml index c872f33..19044bc 100644 --- a/railway.web.toml +++ b/railway.web.toml @@ -1,8 +1,15 @@ # Railway configuration for the Web Application (SvelteKit) +# +# See railway.toml for why the packageManager field and the committed +# pnpm-lock.yaml matter to Railpack's package-manager detection. +# +# The Supabase environment variables are gone: the app reads TURSO_DATABASE_URL +# and TURSO_AUTH_TOKEN at runtime, and neither is a build-time input, so nothing +# needs threading through the build command any more. [build] -buildCommand = "npm install -g pnpm && pnpm install && cd apps/web && PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY pnpm run build" +buildCommand = "pnpm install --frozen-lockfile && cd apps/web && pnpm run build" [deploy] startCommand = "cd apps/web && node build/index.js" restartPolicyType = "ON_FAILURE" -restartPolicyMaxRetries = 10 \ No newline at end of file +restartPolicyMaxRetries = 10 diff --git a/scripts/db-migrate.js b/scripts/db-migrate.js index f4a686b..29b2cad 100644 --- a/scripts/db-migrate.js +++ b/scripts/db-migrate.js @@ -1,80 +1,231 @@ #!/usr/bin/env node -import { existsSync, readlinkSync } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; -import { execSync } from "child_process"; +/** + * MeshHook migration runner (libSQL / Turso). + * + * The previous version shelled out to `supabase db push` / `supabase db reset`, + * which required the Supabase CLI, a linked project and Docker. This applies + * the SQL in migrations/ directly over the libSQL client instead, so the same + * command works against a local file, an embedded replica or Turso. + * + * Applied migrations are recorded in schema_migrations along with a checksum, + * so re-running is a no-op and editing an already-applied file is reported as + * an error rather than silently ignored. + * + * Usage: + * node scripts/db-migrate.js apply pending migrations + * node scripts/db-migrate.js --status list applied/pending, apply nothing + * node scripts/db-migrate.js --dry-run show what would run + * node scripts/db-migrate.js --force apply even if a checksum changed + */ -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { db } from "@meshhook/shared/lib/db.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, ".."); -const ENV_SYMLINK = join(rootDir, ".env"); +const migrationsDir = join(rootDir, "migrations"); -async function migrate() { - console.log("🔄 MeshHook Database Migration\n"); +const args = new Set(process.argv.slice(2)); +const statusOnly = args.has("--status"); +const dryRun = args.has("--dry-run"); +const force = args.has("--force"); - // Detect current environment from .env symlink - let environment = "unknown"; - let envFile = ".env"; +const checksum = (sql) => createHash("sha256").update(sql).digest("hex").slice(0, 16); - if (existsSync(ENV_SYMLINK)) { - try { - const target = readlinkSync(ENV_SYMLINK); - envFile = target; - - if (target.includes(".env.local")) { - environment = "local"; - } else if (target.includes(".env.staging")) { - environment = "staging"; - } else if (target.includes(".env.production")) { - environment = "production"; +/** + * Split a migration file into individual statements. + * + * libSQL executes one statement per call, so the file has to be split. A naive + * split on ";" breaks CREATE TRIGGER, whose body contains statement + * terminators, so BEGIN…END blocks are tracked and kept intact. String + * literals and comments are skipped so a ";" inside either is not treated as a + * boundary. + */ +export function splitStatements(sql) { + const statements = []; + let current = ""; + let depth = 0; // BEGIN…END nesting inside a trigger body + + for (let i = 0; i < sql.length; i++) { + const ch = sql[i]; + + if (ch === "'" || ch === '"') { + let j = i + 1; + while (j < sql.length) { + if (sql[j] === ch) { + if (sql[j + 1] === ch) { + j += 2; + continue; + } + break; + } + j++; } - } catch (error) { - console.log("⚠️ Could not read .env symlink, assuming direct .env file"); + current += sql.slice(i, j + 1); + i = j; + continue; + } + + if (ch === "-" && sql[i + 1] === "-") { + const end = sql.indexOf("\n", i); + const stop = end === -1 ? sql.length : end; + current += sql.slice(i, stop); + i = stop - 1; + continue; + } + + // Track BEGIN/END only at word boundaries so "begins" or a column named + // "end" does not shift the depth. + const rest = sql.slice(i); + const beginMatch = /^\bbegin\b/i.exec(rest); + if (beginMatch && !/^\bbegin\s+(transaction|deferred|immediate|exclusive)\b/i.test(rest)) { + depth++; + current += beginMatch[0]; + i += beginMatch[0].length - 1; + continue; + } + const endMatch = /^\bend\b/i.exec(rest); + if (endMatch && depth > 0) { + depth--; + current += endMatch[0]; + i += endMatch[0].length - 1; + continue; } + + if (ch === ";" && depth === 0) { + if (current.trim()) statements.push(current.trim()); + current = ""; + continue; + } + + current += ch; } - console.log(`📍 Environment: ${environment}`); - console.log(`📄 Using: ${envFile}\n`); + if (current.trim()) statements.push(current.trim()); + return statements.filter((s) => !/^(--[^\n]*\n?)*$/.test(s)); +} + +async function ensureLedger() { + await db.none(` + create table if not exists schema_migrations ( + version text primary key, + checksum text not null, + applied_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ) + `); +} - if (environment === "unknown") { - console.log("⚠️ Warning: Could not detect environment from .env symlink"); - console.log("💡 Run 'pnpm run setup' to configure your environment\n"); +function loadMigrations() { + if (!existsSync(migrationsDir)) { + throw new Error(`No migrations directory at ${migrationsDir}`); } + return readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort() // zero-padded numeric prefixes make lexical order correct + .map((file) => { + const sql = readFileSync(join(migrationsDir, file), "utf8"); + return { version: file.replace(/\.sql$/, ""), file, sql, checksum: checksum(sql) }; + }); +} + +async function main() { + console.log("🔄 MeshHook database migration (Turso/libSQL)\n"); + + const target = process.env.TURSO_DATABASE_URL ?? process.env.DATABASE_URL ?? "(unset)"; + // Never print the token; the URL alone identifies the target. + console.log(`📍 Target: ${target}\n`); + + await ensureLedger(); - try { - if (environment === "local") { - console.log("🔄 Running local migration (db reset)...\n"); - execSync("pnpx supabase db reset", { - stdio: "inherit", - cwd: rootDir, - }); - console.log("\n✅ Local database reset complete!"); - } else { - console.log("🚀 Pushing migrations to remote database...\n"); - execSync("pnpx supabase db push", { - stdio: "inherit", - cwd: rootDir, - }); - console.log("\n✅ Migrations pushed successfully!"); + const applied = new Map( + (await db.manyOrNone("select version, checksum from schema_migrations")).map((r) => [ + r.version, + r.checksum, + ]), + ); + + const migrations = loadMigrations(); + const pending = []; + const drifted = []; + + for (const m of migrations) { + const prior = applied.get(m.version); + if (prior === undefined) { + pending.push(m); + } else if (prior !== m.checksum) { + drifted.push(m); } - } catch (error) { - console.error("\n❌ Migration failed!"); - console.error("Error:", error.message); - - if (environment === "local") { - console.log("\n💡 Make sure Supabase is running:"); - console.log(" pnpx supabase start"); - } else { - console.log("\n💡 Make sure you're linked to your Supabase project:"); - console.log(" pnpx supabase link --project-ref "); + } + + if (statusOnly) { + for (const m of migrations) { + const prior = applied.get(m.version); + const mark = + prior === undefined ? "pending" : prior === m.checksum ? "applied" : "CHANGED"; + console.log(` ${mark.padEnd(8)} ${m.file}`); } + console.log(`\n${applied.size} applied, ${pending.length} pending, ${drifted.length} changed`); + await db.close(); + return; + } - process.exit(1); + if (drifted.length && !force) { + console.error("❌ These migrations changed after being applied:\n"); + for (const m of drifted) console.error(` ${m.file}`); + console.error( + "\nEditing an applied migration leaves environments inconsistent. Add a new\n" + + "migration instead, or re-run with --force if you know the target is disposable.", + ); + process.exitCode = 1; + await db.close(); + return; } + + const toRun = force ? [...pending, ...drifted] : pending; + + if (toRun.length === 0) { + console.log("✅ Database is up to date — nothing to apply."); + await db.close(); + return; + } + + for (const m of toRun) { + const statements = splitStatements(m.sql); + console.log(`${dryRun ? "🔍" : "▶️ "} ${m.file} (${statements.length} statements)`); + + if (dryRun) continue; + + // Each migration is atomic. libSQL has no transactional DDL limitation the + // way some engines do, so a failure part-way leaves nothing behind. + try { + await db.batch([ + ...statements, + { + sql: `insert into schema_migrations (version, checksum) values (?, ?) + on conflict (version) do update set checksum = excluded.checksum, + applied_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, + args: [m.version, m.checksum], + }, + ]); + console.log(` ✅ applied`); + } catch (error) { + console.error(` ❌ failed: ${error.message}`); + throw error; + } + } + + console.log(`\n✅ ${dryRun ? "Would apply" : "Applied"} ${toRun.length} migration(s).`); + await db.close(); } -migrate().catch((error) => { - console.error("❌ Migration script failed:", error); - process.exit(1); -}); \ No newline at end of file +// Only run when invoked directly, so splitStatements stays unit-testable. +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error(`\n❌ Migration failed: ${error.message}`); + process.exit(1); + }); +} diff --git a/scripts/generate-todo.mjs b/scripts/generate-todo.mjs new file mode 100644 index 0000000..d41509b --- /dev/null +++ b/scripts/generate-todo.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node + +/** + * Regenerate TODO.md from the GitHub issue tracker. + * + * Every issue on profullstack/meshhook carries a generated PRD body with a + * consistent shape ("**Milestone:** …", then "## 1. Overview"), so the summary + * line for each entry is pulled straight from its Overview rather than being + * hand-written and going stale. + * + * Usage: + * gh issue list -R profullstack/meshhook --state all --limit 300 \ + * --json number,title,state,labels,url,body > issues.json + * node scripts/generate-todo.mjs issues.json > TODO.md + * + * With no argument it shells out to `gh` itself. + */ + +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; + +const REPO = "profullstack/meshhook"; + +/** + * Sort key for a milestone. Parses the phase number so "Phase 10" sorts after + * "Phase 9" — a plain string comparison, or prefix matching against a list, + * puts "Phase 10" first because it starts with "Phase 1". + * Anything without a phase number sorts to the end. + */ +function phaseRank(milestone) { + const n = /^Phase\s+(\d+)/i.exec(milestone)?.[1]; + return n === undefined ? Number.MAX_SAFE_INTEGER : Number(n); +} + +/** + * Reproduce GitHub's heading-anchor rules: lowercase, drop punctuation, then + * turn each remaining space into a hyphen. Note the spaces are not collapsed + * first — "Polish & Launch" loses the "&" and keeps both surrounding spaces, + * yielding "polish--launch". + */ +function anchorFor(heading) { + return heading + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s/g, "-"); +} + +function loadIssues(path) { + if (path) return JSON.parse(readFileSync(path, "utf8")); + + const out = execFileSync( + "gh", + [ + "issue", "list", "-R", REPO, "--state", "all", "--limit", "300", + "--json", "number,title,state,labels,url,body", + ], + { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, + ); + return JSON.parse(out); +} + +/** Pull "Phase 3: Execution Engine" out of the PRD header. */ +function milestoneOf(body) { + const m = /\*\*Milestone:\*\*\s*(.+)/.exec(body ?? ""); + return m ? m[1].trim() : "Unscheduled"; +} + +/** + * Condense the PRD's Overview section into one sentence. + * + * Falls back through the document if the Overview heading is missing, so an + * issue written by hand still gets a usable line. + */ +function summarize(body) { + if (!body?.trim()) return "No description provided."; + + const text = body + // Drop the generated PRD header block, which is metadata, not content. + .replace(/^#\s*📋[\s\S]*?---\n/, "") + .replace(/```[\s\S]*?```/g, " "); + + const overview = + /##\s*\d*\.?\s*Overview\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i.exec(text)?.[1] ?? + text.replace(/^#.*$/gm, ""); + + const cleaned = overview + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\[(.+?)\]\(.+?\)/g, "$1") + .replace(/^[-*]\s+/gm, "") + .replace(/\s+/g, " ") + .trim(); + + if (!cleaned) return "No description provided."; + + // First sentence, capped so the table stays readable. + const sentence = /^(.+?[.!?])(\s|$)/.exec(cleaned)?.[1] ?? cleaned; + const summary = sentence.length > 240 ? `${sentence.slice(0, 237).trimEnd()}…` : sentence; + return summary; +} + +/** Topic labels only — every issue also carries "hacktoberfest", which says nothing. */ +function topicLabels(issue) { + return issue.labels.map((l) => l.name).filter((n) => n !== "hacktoberfest"); +} + +const issues = loadIssues(process.argv[2]); +const open = issues.filter((i) => i.state === "OPEN"); +const closed = issues.filter((i) => i.state === "CLOSED"); + +const byMilestone = new Map(); +for (const issue of open) { + const ms = milestoneOf(issue.body); + if (!byMilestone.has(ms)) byMilestone.set(ms, []); + byMilestone.get(ms).push(issue); +} + +const milestones = [...byMilestone.keys()].sort( + (a, b) => phaseRank(a) - phaseRank(b) || a.localeCompare(b), +); + +const lines = []; + +lines.push("# MeshHook TODO"); +lines.push(""); +lines.push( + `Every open issue on [${REPO}](https://github.com/${REPO}/issues), grouped by milestone. ` + + `**${open.length} open**, ${closed.length} closed, ${issues.length} total.`, +); +lines.push(""); +lines.push( + "Summaries are extracted from each issue's PRD overview. Regenerate with " + + "`node scripts/generate-todo.mjs > TODO.md`.", +); +lines.push(""); + +lines.push("## Contents"); +lines.push(""); +for (const ms of milestones) { + lines.push(`- [${ms}](#${anchorFor(ms)}) — ${byMilestone.get(ms).length} open`); +} +lines.push(""); + +for (const ms of milestones) { + const group = byMilestone.get(ms).sort((a, b) => a.number - b.number); + lines.push(`## ${ms}`); + lines.push(""); + + for (const issue of group) { + const labels = topicLabels(issue); + const tag = labels.length ? ` \`${labels.join("` `")}\`` : ""; + lines.push(`- [ ] **[#${issue.number}](${issue.url}) ${issue.title}**${tag}`); + lines.push(` ${summarize(issue.body)}`); + } + lines.push(""); +} + +lines.push("---"); +lines.push(""); +lines.push( + `Generated from the GitHub issue tracker. ${open.length} open issues across ` + + `${milestones.length} milestones.`, +); + +process.stdout.write(lines.join("\n") + "\n"); diff --git a/scripts/setup-local.js b/scripts/setup-local.js deleted file mode 100644 index c375aec..0000000 --- a/scripts/setup-local.js +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env node - -import inquirer from "inquirer"; -import { writeFileSync, existsSync, unlinkSync } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const rootDir = join(__dirname, ".."); - -const LOCAL_ENV_FILE = join(rootDir, ".env.local"); -const ENV_SYMLINK = join(rootDir, ".env"); - -async function setupLocal() { - console.log("🔧 MeshHook Local Environment Setup\n"); - - // Check if .env.local already exists - if (existsSync(LOCAL_ENV_FILE)) { - const { overwrite } = await inquirer.prompt([ - { - type: "confirm", - name: "overwrite", - message: ".env.local already exists. Overwrite it?", - default: false, - }, - ]); - - if (!overwrite) { - console.log("✅ Keeping existing .env.local"); - await createSymlink(); - return; - } - } - - const answers = await inquirer.prompt([ - { - type: "input", - name: "databaseUrl", - message: "PostgreSQL connection string:", - default: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", - validate: (input) => - input.startsWith("postgresql://") || "Must be a valid PostgreSQL URL", - }, - { - type: "input", - name: "supabaseUrl", - message: "Supabase API URL:", - default: "http://127.0.0.1:54321", - validate: (input) => input.startsWith("http") || "Must be a valid URL", - }, - { - type: "input", - name: "supabaseAnonKey", - message: "Supabase Anon Key:", - default: - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", - }, - { - type: "input", - name: "supabaseServiceRoleKey", - message: "Supabase Service Role Key:", - default: - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", - }, - { - type: "input", - name: "port", - message: "Application port:", - default: "3000", - validate: (input) => { - const port = parseInt(input); - return ( - (!isNaN(port) && port > 0 && port < 65536) || - "Must be a valid port number" - ); - }, - }, - ]); - - const envContent = `# Local Development Environment Variables -# This file is committed to GitHub for easy local setup -# DO NOT put production secrets here! - -# Supabase Local Development -DATABASE_URL=${answers.databaseUrl} -SUPABASE_URL=${answers.supabaseUrl} -SUPABASE_ANON_KEY=${answers.supabaseAnonKey} -SUPABASE_SERVICE_ROLE_KEY=${answers.supabaseServiceRoleKey} - -# Application Settings -NODE_ENV=development -PORT=${answers.port} -`; - - try { - writeFileSync(LOCAL_ENV_FILE, envContent, "utf8"); - console.log(`\n✅ Created ${LOCAL_ENV_FILE}`); - - await createSymlink(); - - console.log("\n🎉 Local environment setup complete!"); - console.log("\nNext steps:"); - console.log(" 1. Run: pnpx supabase start"); - console.log(" 2. Run: pnpm run dev"); - } catch (error) { - console.error("❌ Error creating .env.local:", error.message); - process.exit(1); - } -} - -async function createSymlink() { - try { - // Remove existing symlink if it exists - if (existsSync(ENV_SYMLINK)) { - unlinkSync(ENV_SYMLINK); - } - - // Create symlink from .env to .env.local - const { symlinkSync } = await import("fs"); - symlinkSync(".env.local", ENV_SYMLINK); - console.log("✅ Created symlink: .env -> .env.local"); - } catch (error) { - console.error("⚠️ Could not create symlink:", error.message); - console.log("💡 You can manually copy .env.local to .env if needed"); - } -} - -setupLocal().catch((error) => { - console.error("❌ Setup failed:", error); - process.exit(1); -}); \ No newline at end of file diff --git a/scripts/setup-production.js b/scripts/setup-production.js deleted file mode 100644 index e23a68d..0000000 --- a/scripts/setup-production.js +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env node - -import inquirer from "inquirer"; -import { writeFileSync, existsSync, unlinkSync } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const rootDir = join(__dirname, ".."); - -const PRODUCTION_ENV_FILE = join(rootDir, ".env.production"); -const ENV_SYMLINK = join(rootDir, ".env"); - -async function setupProduction() { - console.log("🚀 MeshHook Production Environment Setup\n"); - console.log("⚠️ WARNING: This will create .env.production with sensitive credentials."); - console.log("⚠️ Make sure .env.production is in .gitignore!\n"); - - // Check if .env.production already exists - if (existsSync(PRODUCTION_ENV_FILE)) { - const { overwrite } = await inquirer.prompt([ - { - type: "confirm", - name: "overwrite", - message: ".env.production already exists. Overwrite it?", - default: false, - }, - ]); - - if (!overwrite) { - console.log("✅ Keeping existing .env.production"); - await createSymlink(); - return; - } - } - - console.log("\n📝 Enter your Supabase production credentials:"); - console.log(" (Find these in your Supabase project dashboard)\n"); - - const answers = await inquirer.prompt([ - { - type: "input", - name: "projectRef", - message: "Supabase Project Reference (e.g., abcdefghijklmnop):", - validate: (input) => - input.length > 0 || "Project reference is required", - }, - { - type: "password", - name: "dbPassword", - message: "Database Password:", - validate: (input) => input.length > 0 || "Database password is required", - mask: "*", - }, - { - type: "input", - name: "supabaseAnonKey", - message: "Supabase Anon/Public Key:", - validate: (input) => input.length > 0 || "Anon key is required", - }, - { - type: "password", - name: "supabaseServiceRoleKey", - message: "Supabase Service Role Key:", - validate: (input) => input.length > 0 || "Service role key is required", - mask: "*", - }, - { - type: "input", - name: "port", - message: "Application port:", - default: "3000", - validate: (input) => { - const port = parseInt(input); - return ( - (!isNaN(port) && port > 0 && port < 65536) || - "Must be a valid port number" - ); - }, - }, - ]); - - // Construct URLs from project reference - const databaseUrl = `postgresql://postgres:${answers.dbPassword}@db.${answers.projectRef}.supabase.co:5432/postgres`; - const supabaseUrl = `https://${answers.projectRef}.supabase.co`; - - const envContent = `# Production Environment Variables -# NEVER commit this file to version control! -# This file contains sensitive production credentials - -# Supabase Production -DATABASE_URL=${databaseUrl} -SUPABASE_URL=${supabaseUrl} -SUPABASE_ANON_KEY=${answers.supabaseAnonKey} -SUPABASE_SERVICE_ROLE_KEY=${answers.supabaseServiceRoleKey} - -# Application Settings -NODE_ENV=production -PORT=${answers.port} -`; - - try { - writeFileSync(PRODUCTION_ENV_FILE, envContent, "utf8"); - console.log(`\n✅ Created ${PRODUCTION_ENV_FILE}`); - - await createSymlink(); - - console.log("\n🎉 Production environment setup complete!"); - console.log("\n⚠️ IMPORTANT SECURITY REMINDERS:"); - console.log(" • .env.production contains sensitive credentials"); - console.log(" • Verify .env.production is in .gitignore"); - console.log(" • Never commit or share this file"); - console.log(" • Rotate keys if accidentally exposed"); - console.log("\nNext steps:"); - console.log(" 1. Verify your Supabase connection"); - console.log(" 2. Run migrations: pnpx supabase db push"); - console.log(" 3. Deploy your application"); - } catch (error) { - console.error("❌ Error creating .env.production:", error.message); - process.exit(1); - } -} - -async function createSymlink() { - try { - // Remove existing symlink if it exists - if (existsSync(ENV_SYMLINK)) { - unlinkSync(ENV_SYMLINK); - } - - // Create symlink from .env to .env.production - const { symlinkSync } = await import("fs"); - symlinkSync(".env.production", ENV_SYMLINK); - console.log("✅ Created symlink: .env -> .env.production"); - } catch (error) { - console.error("⚠️ Could not create symlink:", error.message); - console.log("💡 You can manually copy .env.production to .env if needed"); - } -} - -setupProduction().catch((error) => { - console.error("❌ Setup failed:", error); - process.exit(1); -}); \ No newline at end of file diff --git a/scripts/setup.js b/scripts/setup.js index ee9da25..06ce5cb 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -1,83 +1,107 @@ #!/usr/bin/env node +/** + * Interactive environment setup. + * + * Rewritten for Turso. The Supabase version collected a Postgres connection + * string plus anon and service-role keys, and told the user to run + * `supabase start` for local work. Local development now needs nothing running + * at all — a `file:` URL is a plain SQLite file — and production needs a + * database URL and token from the Turso CLI. + */ + +import { writeFileSync, existsSync, readFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; import inquirer from "inquirer"; -import { writeFileSync, existsSync, unlinkSync } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, ".."); -const ENV_SYMLINK = join(rootDir, ".env"); -// Environment configurations const ENVIRONMENTS = { local: { - name: "Local Development", + name: "Local development", file: ".env.local", - defaults: { - databaseUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", - supabaseUrl: "http://127.0.0.1:54321", - supabaseAnonKey: - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", - supabaseServiceRoleKey: - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", - nodeEnv: "development", - port: "8080", - }, - committed: true, - }, - staging: { - name: "Staging", - file: ".env.staging", - defaults: { - nodeEnv: "staging", - port: "8080", - }, - committed: false, - }, - production: { - name: "Production", - file: ".env.production", - defaults: { - nodeEnv: "production", - port: "8080", - }, + // A file URL keeps local work dependency-free; no server, no container. + defaultUrl: "file:./meshhook.db", committed: false, }, + staging: { name: "Staging", file: ".env.staging", defaultUrl: "", committed: false }, + production: { name: "Production", file: ".env.production", defaultUrl: "", committed: false }, }; -async function setup() { - console.log("🔧 MeshHook Environment Setup\n"); +/** Reject anything that is not a libSQL-compatible URL. */ +function validateUrl(input) { + const value = input.trim(); + if (!value) return "A database URL is required"; + if (/^postgres(ql)?:\/\//i.test(value)) { + return "That is a Postgres URL. MeshHook now uses Turso — expected libsql:// or file:"; + } + if (!/^(libsql|file|https?):\/\//i.test(value) && !value.startsWith("file:")) { + return "Expected a libsql://, https:// or file: URL"; + } + return true; +} + +function buildEnv({ url, token, encryptionKey, openaiKey, environment }) { + const lines = [ + `# MeshHook ${ENVIRONMENTS[environment].name} environment`, + `# Generated by scripts/setup.js`, + "", + "# Database (Turso / libSQL)", + `TURSO_DATABASE_URL=${url}`, + ]; + + // A file: database is local and unauthenticated; a token would be meaningless. + if (token) { + lines.push(`TURSO_AUTH_TOKEN=${token}`); + } + + lines.push( + "", + "# Encrypts the secrets table (AES-256-GCM). Losing this makes secrets unrecoverable.", + `SECRETS_ENCRYPTION_KEY=${encryptionKey}`, + "", + "# Integrations", + `OPENAI_API_KEY=${openaiKey ?? ""}`, + "", + "# Application", + `NODE_ENV=${environment === "local" ? "development" : environment}`, + "PORT=8080", + "", + ); + + return lines.join("\n"); +} + +/** Preserve an existing key so re-running setup cannot orphan stored secrets. */ +function existingEncryptionKey(envPath) { + if (!existsSync(envPath)) return null; + const match = /^SECRETS_ENCRYPTION_KEY=(.+)$/m.exec(readFileSync(envPath, "utf8")); + const value = match?.[1]?.trim(); + return value || null; +} + +async function main() { + console.log("🔧 MeshHook environment setup\n"); - // Select environment const { environment } = await inquirer.prompt([ { type: "list", name: "environment", - message: "Select environment to configure:", - choices: [ - { name: "🏠 Local Development", value: "local" }, - { name: "🧪 Staging", value: "staging" }, - { name: "🚀 Production", value: "production" }, - ], + message: "Which environment are you configuring?", + choices: Object.entries(ENVIRONMENTS).map(([value, cfg]) => ({ + name: cfg.name, + value, + })), }, ]); const config = ENVIRONMENTS[environment]; - const envFile = join(rootDir, config.file); - - console.log(`\n📝 Configuring ${config.name} environment\n`); + const envPath = join(rootDir, config.file); - if (!config.committed) { - console.log( - "⚠️ WARNING: This will create a file with sensitive credentials." - ); - console.log(`⚠️ Make sure ${config.file} is in .gitignore!\n`); - } - - // Check if file already exists - if (existsSync(envFile)) { + if (existsSync(envPath)) { const { overwrite } = await inquirer.prompt([ { type: "confirm", @@ -86,211 +110,62 @@ async function setup() { default: false, }, ]); - if (!overwrite) { - console.log(`✅ Keeping existing ${config.file}`); - await createSymlink(config.file); + console.log("Aborted; nothing was changed."); return; } } - let answers; - - if (environment === "local") { - // Local development - use defaults or customize - const { useDefaults } = await inquirer.prompt([ - { - type: "confirm", - name: "useDefaults", - message: "Use default local development settings?", - default: true, - }, - ]); - - if (useDefaults) { - answers = config.defaults; - } else { - answers = await promptForConfig(environment, config.defaults); - } - } else { - // Staging/Production - require Supabase credentials - answers = await promptForProductionConfig(environment, config.defaults); - } - - const envContent = generateEnvContent(environment, config, answers); - - try { - writeFileSync(envFile, envContent, "utf8"); - console.log(`\n✅ Created ${config.file}`); - - await createSymlink(config.file); - - console.log(`\n🎉 ${config.name} environment setup complete!`); - - if (!config.committed) { - console.log("\n⚠️ IMPORTANT SECURITY REMINDERS:"); - console.log(` • ${config.file} contains sensitive credentials`); - console.log(` • Verify ${config.file} is in .gitignore`); - console.log(" • Never commit or share this file"); - console.log(" • Rotate keys if accidentally exposed"); - } - - console.log("\nNext steps:"); - if (environment === "local") { - console.log(" 1. Run: pnpx supabase start"); - console.log(" 2. Run: pnpm run db:migrate"); - console.log(" 3. Run: pnpm run dev"); - } else { - console.log(" 1. Run: pnpm run db:migrate"); - console.log(" 2. Deploy your application"); - } - } catch (error) { - console.error(`❌ Error creating ${config.file}:`, error.message); - process.exit(1); - } -} - -async function promptForConfig(environment, defaults) { - return await inquirer.prompt([ - { - type: "input", - name: "databaseUrl", - message: "PostgreSQL connection string:", - default: defaults.databaseUrl, - validate: (input) => - input.startsWith("postgresql://") || "Must be a valid PostgreSQL URL", - }, - { - type: "input", - name: "supabaseUrl", - message: "Supabase API URL:", - default: defaults.supabaseUrl, - validate: (input) => input.startsWith("http") || "Must be a valid URL", - }, - { - type: "input", - name: "supabaseAnonKey", - message: "Supabase Anon Key:", - default: defaults.supabaseAnonKey, - }, - { - type: "input", - name: "supabaseServiceRoleKey", - message: "Supabase Service Role Key:", - default: defaults.supabaseServiceRoleKey, - }, - { - type: "input", - name: "port", - message: "Application port:", - default: defaults.port, - validate: (input) => { - const port = parseInt(input); - return ( - (!isNaN(port) && port > 0 && port < 65536) || - "Must be a valid port number" - ); - }, - }, - ]); -} - -async function promptForProductionConfig(environment, defaults) { - console.log("Enter your Supabase project credentials:"); - console.log("(Find these in your Supabase project dashboard)\n"); - const answers = await inquirer.prompt([ { type: "input", - name: "projectRef", - message: "Supabase Project Reference (e.g., abcdefghijklmnop):", - validate: (input) => - input.length > 0 || "Project reference is required", - }, - { - type: "password", - name: "dbPassword", - message: "Database Password:", - validate: (input) => input.length > 0 || "Database password is required", - mask: "*", - }, - { - type: "input", - name: "supabaseAnonKey", - message: "Supabase Anon/Public Key:", - validate: (input) => input.length > 0 || "Anon key is required", + name: "url", + message: "Turso database URL (or file: path for local):", + default: config.defaultUrl || undefined, + validate: validateUrl, + filter: (v) => v.trim(), }, { type: "password", - name: "supabaseServiceRoleKey", - message: "Supabase Service Role Key:", - validate: (input) => input.length > 0 || "Service role key is required", + name: "token", + message: "Turso auth token (create with: turso db tokens create ):", mask: "*", + // Only a remote database needs one. + when: (a) => !a.url.startsWith("file:"), + validate: (input) => (input.trim() ? true : "A token is required for remote databases"), + filter: (v) => v.trim(), }, { type: "input", - name: "port", - message: "Application port:", - default: defaults.port, - validate: (input) => { - const port = parseInt(input); - return ( - (!isNaN(port) && port > 0 && port < 65536) || - "Must be a valid port number" - ); - }, + name: "openaiKey", + message: "OpenAI API key (optional, press enter to skip):", + filter: (v) => v.trim(), }, ]); - // Construct URLs from project reference - answers.databaseUrl = `postgresql://postgres:${answers.dbPassword}@db.${answers.projectRef}.supabase.co:5432/postgres`; - answers.supabaseUrl = `https://${answers.projectRef}.supabase.co`; + // Reuse the key if one is already present; a new key would strand every + // secret already encrypted under the old one. + const reused = existingEncryptionKey(envPath); + const encryptionKey = reused ?? randomBytes(32).toString("hex"); - return answers; -} - -function generateEnvContent(environment, config, answers) { - const isLocal = environment === "local"; - const header = isLocal - ? `# Local Development Environment Variables -# This file is committed to GitHub for easy local setup -# DO NOT put production secrets here!` - : `# ${config.name} Environment Variables -# NEVER commit this file to version control! -# This file contains sensitive ${environment} credentials`; - - return `${header} + writeFileSync(envPath, buildEnv({ ...answers, encryptionKey, environment }), "utf8"); -# Supabase ${config.name} -DATABASE_URL=${answers.databaseUrl} -SUPABASE_URL=${answers.supabaseUrl} -SUPABASE_ANON_KEY=${answers.supabaseAnonKey} -SUPABASE_SERVICE_ROLE_KEY=${answers.supabaseServiceRoleKey} - -# Application Settings -NODE_ENV=${config.defaults.nodeEnv} -PORT=${answers.port} -`; -} + console.log(`\n✅ Wrote ${config.file}`); + if (reused) { + console.log(" Kept the existing SECRETS_ENCRYPTION_KEY."); + } else { + console.log(" Generated a new SECRETS_ENCRYPTION_KEY — back it up."); + } -async function createSymlink(targetFile) { - try { - // Remove existing symlink if it exists - if (existsSync(ENV_SYMLINK)) { - unlinkSync(ENV_SYMLINK); - } + console.log("\n⚠️ This file holds credentials. Keep it out of version control."); - // Create symlink from .env to target file - const { symlinkSync } = await import("fs"); - symlinkSync(targetFile, ENV_SYMLINK); - console.log(`✅ Created symlink: .env -> ${targetFile}`); - } catch (error) { - console.error("⚠️ Could not create symlink:", error.message); - console.log(`💡 You can manually copy ${targetFile} to .env if needed`); - } + console.log("\nNext steps:"); + console.log(` 1. Point .env at it: ln -sf ${config.file} .env`); + console.log(" 2. Apply the schema: pnpm run db:migrate"); + console.log(" 3. Start the app: pnpm run dev"); } -setup().catch((error) => { - console.error("❌ Setup failed:", error); +main().catch((error) => { + console.error(`\n❌ Setup failed: ${error.message}`); process.exit(1); -}); \ No newline at end of file +}); diff --git a/scripts/socket-patch.mjs b/scripts/socket-patch.mjs new file mode 100644 index 0000000..2a7fab8 --- /dev/null +++ b/scripts/socket-patch.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +/** + * Apply Socket.dev security patches, as a postinstall step. + * + * This used to be inline in package.json: + * + * "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm && ..." + * + * which broke every deploy. `npx` downloads the package on each install, and + * the patcher exits non-zero when SOCKET_API_TOKEN is unset — so an install in + * any environment without a token failed outright, taking the build with it. + * That is what Railway hit; it is not specific to npm or pnpm. + * + * Patching is a hardening step, not a build requirement, so this wrapper: + * - skips entirely when no token is configured, + * - never exits non-zero, whatever the patcher does, + * - stays quiet unless something is worth reporting. + * + * Set SOCKET_API_TOKEN to enable it. Set SKIP_SOCKET_PATCH=1 to force-skip. + */ + +import { spawnSync } from "node:child_process"; + +const token = process.env.SOCKET_API_TOKEN; + +if (process.env.SKIP_SOCKET_PATCH === "1") { + console.log("• Socket patches skipped (SKIP_SOCKET_PATCH=1)"); + process.exit(0); +} + +if (!token) { + // The free proxy the patcher falls back to is what exits 1, so there is + // nothing to gain by running it here. + console.log("• Socket patches skipped (no SOCKET_API_TOKEN set)"); + process.exit(0); +} + +const result = spawnSync( + "npx", + ["--yes", "@socketsecurity/socket-patch", "apply", "--silent", "--ecosystems", "npm"], + { stdio: "inherit", shell: process.platform === "win32" }, +); + +if (result.error) { + console.warn(`⚠ Socket patch step could not run: ${result.error.message}`); +} else if (result.status !== 0) { + console.warn(`⚠ Socket patch step exited ${result.status}; continuing anyway.`); +} else { + console.log("✅ Socket patches applied"); +} + +// Always succeed: a failed hardening pass must not fail the install. +process.exit(0); diff --git a/scripts/test-rls-policies.js b/scripts/test-rls-policies.js deleted file mode 100644 index d334a8c..0000000 --- a/scripts/test-rls-policies.js +++ /dev/null @@ -1,551 +0,0 @@ -#!/usr/bin/env node - -/** - * Test script for Row Level Security (RLS) policies - * - * This script verifies that RLS policies correctly enforce multi-tenant data isolation. - * It creates test users and projects, then verifies that users can only access their own data. - * - * Usage: node scripts/test-rls-policies.js - */ - -import pg from 'pg'; -import { config } from 'dotenv'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const rootDir = join(__dirname, '..'); - -// Load environment variables -config({ path: join(rootDir, '.env') }); -config({ path: join(rootDir, '.env.local') }); - -if (!process.env.DATABASE_URL) { - console.error('❌ DATABASE_URL is not set'); - process.exit(1); -} - -const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); - -// Test utilities -const colors = { - reset: '\x1b[0m', - green: '\x1b[32m', - red: '\x1b[31m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - cyan: '\x1b[36m', -}; - -const log = { - success: (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`), - error: (msg) => console.log(`${colors.red}✗${colors.reset} ${msg}`), - info: (msg) => console.log(`${colors.blue}ℹ${colors.reset} ${msg}`), - section: (msg) => console.log(`\n${colors.cyan}${msg}${colors.reset}`), -}; - -// Test data -const testUsers = { - user1: '11111111-1111-1111-1111-111111111111', - user2: '22222222-2222-2222-2222-222222222222', -}; - -let testData = { - user1: {}, - user2: {}, -}; - -/** - * Execute query as a specific user by setting the JWT claim - */ -async function queryAsUser(userId, query, params = []) { - const client = await pool.connect(); - try { - // Set the user context for RLS - await client.query(`SELECT set_config('request.jwt.claims', '{"sub":"${userId}"}', true)`); - const result = await client.query(query, params); - return result.rows; - } finally { - client.release(); - } -} - -/** - * Execute query as admin (bypassing RLS) - */ -async function queryAsAdmin(query, params = []) { - const result = await pool.query(query, params); - return result.rows; -} - -/** - * Clean up test data - */ -async function cleanup() { - log.section('🧹 Cleaning up test data...'); - - try { - // Delete test projects (cascades to all related tables) - await queryAsAdmin( - 'DELETE FROM projects WHERE owner = ANY($1)', - [[testUsers.user1, testUsers.user2]] - ); - log.success('Test data cleaned up'); - } catch (error) { - log.error(`Cleanup failed: ${error.message}`); - } -} - -/** - * Setup test data - */ -async function setupTestData() { - log.section('📝 Setting up test data...'); - - try { - // Create projects for user1 - const user1Projects = await queryAsUser( - testUsers.user1, - `INSERT INTO projects (owner, name) - VALUES ($1, 'User1 Project 1'), ($1, 'User1 Project 2') - RETURNING id, name`, - [testUsers.user1] - ); - testData.user1.projects = user1Projects; - log.success(`Created ${user1Projects.length} projects for user1`); - - // Create projects for user2 - const user2Projects = await queryAsUser( - testUsers.user2, - `INSERT INTO projects (owner, name) - VALUES ($1, 'User2 Project 1') - RETURNING id, name`, - [testUsers.user2] - ); - testData.user2.projects = user2Projects; - log.success(`Created ${user2Projects.length} project for user2`); - - // Create secrets for user1's first project - const user1Secrets = await queryAsUser( - testUsers.user1, - `INSERT INTO secrets (project_id, key, value_encrypted) - VALUES ($1, 'API_KEY', 'encrypted_value_1'), ($1, 'DB_PASSWORD', 'encrypted_value_2') - RETURNING id, key`, - [user1Projects[0].id] - ); - testData.user1.secrets = user1Secrets; - log.success(`Created ${user1Secrets.length} secrets for user1`); - - // Create workflow definition for user1 - const user1Workflows = await queryAsUser( - testUsers.user1, - `INSERT INTO workflow_definitions (project_id, slug, definition) - VALUES ($1, 'test-workflow', '{"nodes": [], "edges": []}') - RETURNING id, slug`, - [user1Projects[0].id] - ); - testData.user1.workflows = user1Workflows; - log.success(`Created ${user1Workflows.length} workflow for user1`); - - // Create workflow run for user1 - const user1Runs = await queryAsUser( - testUsers.user1, - `INSERT INTO workflow_runs (project_id, workflow_id, status) - VALUES ($1, $2, 'running') - RETURNING id, status`, - [user1Projects[0].id, user1Workflows[0].id] - ); - testData.user1.runs = user1Runs; - log.success(`Created ${user1Runs.length} workflow run for user1`); - - // Create workflow events for user1's run - const user1Events = await queryAsUser( - testUsers.user1, - `INSERT INTO workflow_events (run_id, type, payload) - VALUES ($1, 'workflow.started', '{"timestamp": "2025-01-10T00:00:00Z"}') - RETURNING id, type`, - [user1Runs[0].id] - ); - testData.user1.events = user1Events; - log.success(`Created ${user1Events.length} workflow event for user1`); - - // Create audit log entry for user1 - const user1AuditLogs = await queryAsUser( - testUsers.user1, - `INSERT INTO audit_log (project_id, user_id, action, resource_type, resource_id) - VALUES ($1, $2, 'project.created', 'project', $1) - RETURNING id, action`, - [user1Projects[0].id, testUsers.user1] - ); - testData.user1.auditLogs = user1AuditLogs; - log.success(`Created ${user1AuditLogs.length} audit log entry for user1`); - - log.success('Test data setup complete'); - } catch (error) { - log.error(`Setup failed: ${error.message}`); - throw error; - } -} - -/** - * Test: Users can only view their own projects - */ -async function testProjectIsolation() { - log.section('🔒 Testing project isolation...'); - - try { - // User1 should see their own projects - const user1Projects = await queryAsUser( - testUsers.user1, - 'SELECT id, name FROM projects ORDER BY name' - ); - - if (user1Projects.length === 2) { - log.success('User1 can view their own projects'); - } else { - log.error(`User1 should see 2 projects, but saw ${user1Projects.length}`); - return false; - } - - // User2 should only see their own project - const user2Projects = await queryAsUser( - testUsers.user2, - 'SELECT id, name FROM projects ORDER BY name' - ); - - if (user2Projects.length === 1) { - log.success('User2 can view their own project'); - } else { - log.error(`User2 should see 1 project, but saw ${user2Projects.length}`); - return false; - } - - // User2 should not see user1's projects - const user2ViewingUser1 = await queryAsUser( - testUsers.user2, - 'SELECT id FROM projects WHERE id = $1', - [testData.user1.projects[0].id] - ); - - if (user2ViewingUser1.length === 0) { - log.success('User2 cannot view user1\'s projects'); - } else { - log.error('User2 should not be able to view user1\'s projects'); - return false; - } - - return true; - } catch (error) { - log.error(`Project isolation test failed: ${error.message}`); - return false; - } -} - -/** - * Test: Users can only view secrets in their own projects - */ -async function testSecretIsolation() { - log.section('🔐 Testing secret isolation...'); - - try { - // User1 should see their own secrets - const user1Secrets = await queryAsUser( - testUsers.user1, - 'SELECT id, key FROM secrets ORDER BY key' - ); - - if (user1Secrets.length === 2) { - log.success('User1 can view their own secrets'); - } else { - log.error(`User1 should see 2 secrets, but saw ${user1Secrets.length}`); - return false; - } - - // User2 should not see user1's secrets - const user2ViewingUser1Secrets = await queryAsUser( - testUsers.user2, - 'SELECT id FROM secrets WHERE id = $1', - [testData.user1.secrets[0].id] - ); - - if (user2ViewingUser1Secrets.length === 0) { - log.success('User2 cannot view user1\'s secrets'); - } else { - log.error('User2 should not be able to view user1\'s secrets'); - return false; - } - - return true; - } catch (error) { - log.error(`Secret isolation test failed: ${error.message}`); - return false; - } -} - -/** - * Test: Users can only view workflow definitions in their own projects - */ -async function testWorkflowIsolation() { - log.section('📊 Testing workflow definition isolation...'); - - try { - // User1 should see their own workflows - const user1Workflows = await queryAsUser( - testUsers.user1, - 'SELECT id, slug FROM workflow_definitions' - ); - - if (user1Workflows.length === 1) { - log.success('User1 can view their own workflow definitions'); - } else { - log.error(`User1 should see 1 workflow, but saw ${user1Workflows.length}`); - return false; - } - - // User2 should not see user1's workflows - const user2ViewingUser1Workflows = await queryAsUser( - testUsers.user2, - 'SELECT id FROM workflow_definitions WHERE id = $1', - [testData.user1.workflows[0].id] - ); - - if (user2ViewingUser1Workflows.length === 0) { - log.success('User2 cannot view user1\'s workflow definitions'); - } else { - log.error('User2 should not be able to view user1\'s workflow definitions'); - return false; - } - - return true; - } catch (error) { - log.error(`Workflow isolation test failed: ${error.message}`); - return false; - } -} - -/** - * Test: Users can only view workflow runs in their own projects - */ -async function testRunIsolation() { - log.section('🏃 Testing workflow run isolation...'); - - try { - // User1 should see their own runs - const user1Runs = await queryAsUser( - testUsers.user1, - 'SELECT id, status FROM workflow_runs' - ); - - if (user1Runs.length === 1) { - log.success('User1 can view their own workflow runs'); - } else { - log.error(`User1 should see 1 run, but saw ${user1Runs.length}`); - return false; - } - - // User2 should not see user1's runs - const user2ViewingUser1Runs = await queryAsUser( - testUsers.user2, - 'SELECT id FROM workflow_runs WHERE id = $1', - [testData.user1.runs[0].id] - ); - - if (user2ViewingUser1Runs.length === 0) { - log.success('User2 cannot view user1\'s workflow runs'); - } else { - log.error('User2 should not be able to view user1\'s workflow runs'); - return false; - } - - return true; - } catch (error) { - log.error(`Run isolation test failed: ${error.message}`); - return false; - } -} - -/** - * Test: Users can only view workflow events for runs in their own projects - */ -async function testEventIsolation() { - log.section('📝 Testing workflow event isolation...'); - - try { - // User1 should see their own events - const user1Events = await queryAsUser( - testUsers.user1, - 'SELECT id, type FROM workflow_events' - ); - - if (user1Events.length === 1) { - log.success('User1 can view their own workflow events'); - } else { - log.error(`User1 should see 1 event, but saw ${user1Events.length}`); - return false; - } - - // User2 should not see user1's events - const user2ViewingUser1Events = await queryAsUser( - testUsers.user2, - 'SELECT id FROM workflow_events WHERE id = $1', - [testData.user1.events[0].id] - ); - - if (user2ViewingUser1Events.length === 0) { - log.success('User2 cannot view user1\'s workflow events'); - } else { - log.error('User2 should not be able to view user1\'s workflow events'); - return false; - } - - return true; - } catch (error) { - log.error(`Event isolation test failed: ${error.message}`); - return false; - } -} - -/** - * Test: Users can view audit logs for their own projects - */ -async function testAuditLogIsolation() { - log.section('📋 Testing audit log isolation...'); - - try { - // User1 should see their own audit logs - const user1AuditLogs = await queryAsUser( - testUsers.user1, - 'SELECT id, action FROM audit_log' - ); - - if (user1AuditLogs.length === 1) { - log.success('User1 can view their own audit logs'); - } else { - log.error(`User1 should see 1 audit log, but saw ${user1AuditLogs.length}`); - return false; - } - - // User2 should not see user1's audit logs - const user2ViewingUser1AuditLogs = await queryAsUser( - testUsers.user2, - 'SELECT id FROM audit_log WHERE id = $1', - [testData.user1.auditLogs[0].id] - ); - - if (user2ViewingUser1AuditLogs.length === 0) { - log.success('User2 cannot view user1\'s audit logs'); - } else { - log.error('User2 should not be able to view user1\'s audit logs'); - return false; - } - - return true; - } catch (error) { - log.error(`Audit log isolation test failed: ${error.message}`); - return false; - } -} - -/** - * Test: Verify RLS is enabled on all tables - */ -async function testRLSEnabled() { - log.section('🔍 Verifying RLS is enabled on all tables...'); - - try { - const tables = [ - 'projects', - 'secrets', - 'workflow_definitions', - 'workflow_runs', - 'workflow_events', - 'audit_log', - ]; - - for (const table of tables) { - const result = await queryAsAdmin( - `SELECT relrowsecurity - FROM pg_class - WHERE relname = $1`, - [table] - ); - - if (result.length > 0 && result[0].relrowsecurity) { - log.success(`RLS is enabled on ${table}`); - } else { - log.error(`RLS is NOT enabled on ${table}`); - return false; - } - } - - return true; - } catch (error) { - log.error(`RLS verification failed: ${error.message}`); - return false; - } -} - -/** - * Main test runner - */ -async function runTests() { - console.log(`${colors.cyan} -╔═══════════════════════════════════════════════════════════╗ -║ MeshHook RLS Policy Test Suite ║ -║ Testing Multi-Tenant Data Isolation ║ -╚═══════════════════════════════════════════════════════════╝ -${colors.reset}`); - - let allTestsPassed = true; - - try { - // Clean up any existing test data - await cleanup(); - - // Setup test data - await setupTestData(); - - // Run all tests - const tests = [ - { name: 'RLS Enabled', fn: testRLSEnabled }, - { name: 'Project Isolation', fn: testProjectIsolation }, - { name: 'Secret Isolation', fn: testSecretIsolation }, - { name: 'Workflow Isolation', fn: testWorkflowIsolation }, - { name: 'Run Isolation', fn: testRunIsolation }, - { name: 'Event Isolation', fn: testEventIsolation }, - { name: 'Audit Log Isolation', fn: testAuditLogIsolation }, - ]; - - for (const test of tests) { - const passed = await test.fn(); - if (!passed) { - allTestsPassed = false; - } - } - - // Clean up test data - await cleanup(); - - // Print summary - log.section('📊 Test Summary'); - if (allTestsPassed) { - log.success('All RLS policy tests passed! ✨'); - process.exit(0); - } else { - log.error('Some RLS policy tests failed. Please review the output above.'); - process.exit(1); - } - } catch (error) { - log.error(`Test suite failed: ${error.message}`); - console.error(error); - await cleanup(); - process.exit(1); - } finally { - await pool.end(); - } -} - -// Run tests -runTests(); \ No newline at end of file diff --git a/scripts/verify-event-partitioning.js b/scripts/verify-event-partitioning.js deleted file mode 100644 index 649d1c3..0000000 --- a/scripts/verify-event-partitioning.js +++ /dev/null @@ -1,352 +0,0 @@ -#!/usr/bin/env node - -/** - * Verify Event Partitioning Script - * Tests that the event partitioning migration was applied successfully - * and that partitioning is working correctly - */ - -import pg from 'pg'; -const { Client } = pg; - -/** - * Create a database client with connection details - * @returns {Client} PostgreSQL client - */ -function createClient() { - return new Client({ - host: process.env.DB_HOST || 'localhost', - port: process.env.DB_PORT || 54322, - database: process.env.DB_NAME || 'postgres', - user: process.env.DB_USER || 'postgres', - password: process.env.DB_PASSWORD || 'postgres', - }); -} - -/** - * Check if workflow_events table is partitioned - * @param {Client} client - PostgreSQL client - * @returns {Promise} - */ -async function checkTableIsPartitioned(client) { - const result = await client.query(` - SELECT relkind - FROM pg_class - WHERE relname = 'workflow_events' - AND relnamespace = 'public'::regnamespace - `); - - if (result.rows.length === 0) { - throw new Error('workflow_events table not found'); - } - - // 'p' means partitioned table - return result.rows[0].relkind === 'p'; -} - -/** - * Get list of partitions for workflow_events - * @param {Client} client - PostgreSQL client - * @returns {Promise} - */ -async function getPartitions(client) { - const result = await client.query(` - SELECT - c.relname as partition_name, - pg_get_expr(c.relpartbound, c.oid) as partition_bounds, - pg_size_pretty(pg_total_relation_size(c.oid)) as size - FROM pg_class c - JOIN pg_inherits i ON i.inhrelid = c.oid - JOIN pg_class p ON p.oid = i.inhparent - WHERE p.relname = 'workflow_events' - AND c.relkind = 'r' - ORDER BY c.relname - `); - - return result.rows; -} - -/** - * Check if required functions exist - * @param {Client} client - PostgreSQL client - * @returns {Promise} - */ -async function checkFunctions(client) { - const functions = [ - 'create_workflow_events_partition', - 'maintain_workflow_events_partitions', - 'drop_old_workflow_events_partitions', - 'get_workflow_events_partition_stats', - 'get_workflow_run_events', - ]; - - const results = {}; - - for (const funcName of functions) { - const result = await client.query( - `SELECT EXISTS ( - SELECT FROM pg_proc p - JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname = 'public' - AND p.proname = $1 - )`, - [funcName] - ); - results[funcName] = result.rows[0].exists; - } - - return results; -} - -/** - * Check if partition info view exists - * @param {Client} client - PostgreSQL client - * @returns {Promise} - */ -async function checkPartitionView(client) { - const result = await client.query( - `SELECT EXISTS ( - SELECT FROM information_schema.views - WHERE table_schema = 'public' - AND table_name = 'workflow_events_partition_info' - )` - ); - - return result.rows[0].exists; -} - -/** - * Test inserting events into different time periods - * @param {Client} client - PostgreSQL client - * @returns {Promise} - */ -async function testEventInsertion(client) { - console.log('\n🧪 Testing event insertion across partitions...'); - - // First, we need a test project and workflow - const projectResult = await client.query(` - INSERT INTO projects (owner, name) - VALUES ('00000000-0000-0000-0000-000000000000', 'Test Project') - RETURNING id - `); - const projectId = projectResult.rows[0].id; - - const workflowResult = await client.query(` - INSERT INTO workflow_definitions (project_id, slug, version, definition) - VALUES ($1, 'test-workflow', 1, '{}') - RETURNING id - `, [projectId]); - const workflowId = workflowResult.rows[0].id; - - const runResult = await client.query(` - INSERT INTO workflow_runs (project_id, workflow_id, status) - VALUES ($1, $2, 'running') - RETURNING id - `, [projectId, workflowId]); - const runId = runResult.rows[0].id; - - // Test inserting events in different months - const testDates = [ - new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), // 2 months ago - new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // 1 month ago - new Date(), // current - new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 1 month future - ]; - - const insertedEvents = []; - - for (const testDate of testDates) { - try { - const result = await client.query(` - INSERT INTO workflow_events (run_id, ts, type, payload) - VALUES ($1, $2, 'test_event', '{"test": true}') - RETURNING id, ts - `, [runId, testDate]); - - insertedEvents.push(result.rows[0]); - console.log(` ✅ Inserted event for ${testDate.toISOString().split('T')[0]}`); - } catch (error) { - console.log(` ❌ Failed to insert event for ${testDate.toISOString().split('T')[0]}: ${error.message}`); - } - } - - // Verify events can be queried - const queryResult = await client.query(` - SELECT COUNT(*) as count - FROM workflow_events - WHERE run_id = $1 - `, [runId]); - - console.log(` ℹ️ Total events inserted: ${queryResult.rows[0].count}`); - - // Clean up test data - await client.query('DELETE FROM workflow_runs WHERE id = $1', [runId]); - await client.query('DELETE FROM workflow_definitions WHERE id = $1', [workflowId]); - await client.query('DELETE FROM projects WHERE id = $1', [projectId]); - - return { - success: insertedEvents.length > 0, - eventsInserted: insertedEvents.length, - expectedEvents: testDates.length, - }; -} - -/** - * Test the get_workflow_run_events function - * @param {Client} client - PostgreSQL client - * @returns {Promise} - */ -async function testQueryFunction(client) { - console.log('\n🧪 Testing get_workflow_run_events function...'); - - try { - // Just test that the function can be called - const result = await client.query(` - SELECT * FROM get_workflow_run_events( - '00000000-0000-0000-0000-000000000000'::uuid - ) - LIMIT 1 - `); - - console.log(' ✅ Function executed successfully'); - return true; - } catch (error) { - console.log(` ❌ Function test failed: ${error.message}`); - return false; - } -} - -/** - * Test partition maintenance function - * @param {Client} client - PostgreSQL client - * @returns {Promise} - */ -async function testPartitionMaintenance(client) { - console.log('\n🧪 Testing partition maintenance...'); - - try { - await client.query('SELECT maintain_workflow_events_partitions()'); - console.log(' ✅ Partition maintenance executed successfully'); - return true; - } catch (error) { - console.log(` ❌ Partition maintenance failed: ${error.message}`); - return false; - } -} - -/** - * Main verification function - */ -async function verifyEventPartitioning() { - console.log('🔍 Verifying event partitioning setup...\n'); - - const client = createClient(); - let allChecksPassed = true; - - try { - await client.connect(); - console.log('✅ Connected to database\n'); - - // Check 1: Verify table is partitioned - console.log('📋 Checking if workflow_events is partitioned...'); - const isPartitioned = await checkTableIsPartitioned(client); - if (isPartitioned) { - console.log(' ✅ workflow_events is a partitioned table'); - } else { - console.log(' ❌ workflow_events is NOT partitioned'); - allChecksPassed = false; - } - - // Check 2: List partitions - console.log('\n📊 Checking partitions...'); - const partitions = await getPartitions(client); - if (partitions.length > 0) { - console.log(` ✅ Found ${partitions.length} partition(s):`); - partitions.forEach((partition) => { - console.log(` - ${partition.partition_name} (${partition.size})`); - console.log(` Range: ${partition.partition_bounds}`); - }); - } else { - console.log(' ❌ No partitions found'); - allChecksPassed = false; - } - - // Check 3: Verify functions exist - console.log('\n⚙️ Checking partition management functions...'); - const functions = await checkFunctions(client); - for (const [funcName, exists] of Object.entries(functions)) { - if (exists) { - console.log(` ✅ ${funcName}`); - } else { - console.log(` ❌ ${funcName} - NOT FOUND`); - allChecksPassed = false; - } - } - - // Check 4: Verify partition info view - console.log('\n👁️ Checking partition info view...'); - const viewExists = await checkPartitionView(client); - if (viewExists) { - console.log(' ✅ workflow_events_partition_info view exists'); - - // Query the view - const viewResult = await client.query( - 'SELECT * FROM workflow_events_partition_info' - ); - console.log(` ℹ️ View contains ${viewResult.rows.length} partition(s)`); - } else { - console.log(' ❌ workflow_events_partition_info view NOT FOUND'); - allChecksPassed = false; - } - - // Check 5: Test event insertion - const insertionTest = await testEventInsertion(client); - if (!insertionTest.success) { - allChecksPassed = false; - } - - // Check 6: Test query function - const queryTest = await testQueryFunction(client); - if (!queryTest) { - allChecksPassed = false; - } - - // Check 7: Test partition maintenance - const maintenanceTest = await testPartitionMaintenance(client); - if (!maintenanceTest) { - allChecksPassed = false; - } - - // Final summary - console.log('\n' + '='.repeat(60)); - if (allChecksPassed) { - console.log('✅ All partitioning checks passed!'); - console.log('\n📝 Partitioning Summary:'); - console.log(` - Table is partitioned: YES`); - console.log(` - Number of partitions: ${partitions.length}`); - console.log(` - All functions present: YES`); - console.log(` - Event insertion: WORKING`); - console.log(` - Query functions: WORKING`); - console.log('\n💡 Tips:'); - console.log(' - Run maintain_workflow_events_partitions() regularly'); - console.log(' - Monitor partition sizes with workflow_events_partition_info view'); - console.log(' - Use drop_old_workflow_events_partitions(months) to clean old data'); - } else { - console.log('❌ Some partitioning checks failed!'); - console.log(' Please review the errors above and ensure the migration ran successfully.'); - process.exit(1); - } - } catch (error) { - console.error('\n❌ Verification failed:', error.message); - console.error('Stack trace:', error.stack); - process.exit(1); - } finally { - await client.end(); - } -} - -// Run verification -verifyEventPartitioning().catch((error) => { - console.error('❌ Script failed:', error); - process.exit(1); -}); \ No newline at end of file diff --git a/scripts/verify-migration.js b/scripts/verify-migration.js index dcd7754..1818d53 100644 --- a/scripts/verify-migration.js +++ b/scripts/verify-migration.js @@ -1,120 +1,108 @@ #!/usr/bin/env node /** - * Verify Migration Script - * Tests that the core tables migration was applied successfully + * Verify that the database schema matches what the application expects. + * + * The Postgres version checked for RLS policies, partitions and pgmq queues. + * None of those exist on SQLite, so this checks the things that do: every + * table, view and index the migrations create, plus the seeded queue config. + * + * Usage: node scripts/verify-migration.js + * Exits non-zero when anything is missing. */ -import pg from 'pg'; -const { Client } = pg; - -async function verifyMigration() { - console.log('🔍 Verifying core tables migration...\n'); - - const client = new Client({ - host: 'localhost', - port: 54322, - database: 'postgres', - user: 'postgres', - password: 'postgres', - }); - - try { - await client.connect(); - console.log('✅ Connected to database\n'); - - // Check if all tables exist - const tables = [ - 'projects', - 'secrets', - 'workflow_definitions', - 'workflow_runs', - 'workflow_events', - 'audit_log', - ]; - - console.log('📋 Checking tables...'); - for (const table of tables) { - const result = await client.query( - `SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = $1 - )`, - [table] - ); - - if (result.rows[0].exists) { - console.log(` ✅ ${table}`); - } else { - console.log(` ❌ ${table} - NOT FOUND`); - } - } - - // Check indexes - console.log('\n📊 Checking key indexes...'); - const indexes = [ - 'idx_projects_owner', - 'idx_secrets_project_id', - 'idx_workflow_definitions_project_id', - 'idx_workflow_runs_project_started', - 'idx_workflow_events_run_ts', - 'idx_audit_log_project_id', - ]; - - for (const index of indexes) { - const result = await client.query( - `SELECT EXISTS ( - SELECT FROM pg_indexes - WHERE schemaname = 'public' - AND indexname = $1 - )`, - [index] - ); - - if (result.rows[0].exists) { - console.log(` ✅ ${index}`); - } else { - console.log(` ❌ ${index} - NOT FOUND`); - } - } - - // Check triggers - console.log('\n⚡ Checking triggers...'); - const triggers = [ - 'update_projects_updated_at', - 'update_secrets_updated_at', - 'update_workflow_definitions_updated_at', - 'update_workflow_runs_updated_at', - ]; - - for (const trigger of triggers) { - const result = await client.query( - `SELECT EXISTS ( - SELECT FROM information_schema.triggers - WHERE trigger_schema = 'public' - AND trigger_name = $1 - )`, - [trigger] - ); - - if (result.rows[0].exists) { - console.log(` ✅ ${trigger}`); - } else { - console.log(` ❌ ${trigger} - NOT FOUND`); - } - } - - console.log('\n✅ Migration verification complete!'); - } catch (error) { - console.error('\n❌ Verification failed:', error.message); +import { db } from "@meshhook/shared/lib/db.js"; + +const EXPECTED_TABLES = [ + "audit_log", + "job_tracking", + "projects", + "queue_archive", + "queue_config", + "queue_messages", + "schema_migrations", + "secrets", + "sessions", + "user_settings", + "users", + "workflow_definitions", + "workflow_events", + "workflow_runs", +]; + +const EXPECTED_VIEWS = ["workflows"]; + +/** Indexes that carry a hot path; a missing one is a performance regression. */ +const EXPECTED_INDEXES = [ + "idx_workflow_events_run_ts", + "idx_workflow_runs_project_started", + "idx_queue_messages_claim", + "idx_sessions_expires_at", + "idx_users_email", +]; + +const EXPECTED_QUEUES = ["workflow_jobs", "workflow_jobs_dlq"]; + +async function namesOfType(type) { + const rows = await db.manyOrNone( + "select name from sqlite_master where type = ? and name not like 'sqlite_%'", + [type], + ); + return new Set(rows.map((r) => r.name)); +} + +function report(label, expected, actual) { + const missing = expected.filter((name) => !actual.has(name)); + + if (missing.length === 0) { + console.log(`✅ ${label}: all ${expected.length} present`); + return true; + } + + console.error(`❌ ${label}: missing ${missing.join(", ")}`); + return false; +} + +async function main() { + console.log("🔍 Verifying MeshHook schema\n"); + + let ok = true; + + ok = report("Tables", EXPECTED_TABLES, await namesOfType("table")) && ok; + ok = report("Views", EXPECTED_VIEWS, await namesOfType("view")) && ok; + ok = report("Indexes", EXPECTED_INDEXES, await namesOfType("index")) && ok; + + const queues = await db.manyOrNone("select queue_name from queue_config"); + ok = report("Queue config", EXPECTED_QUEUES, new Set(queues.map((q) => q.queue_name))) && ok; + + const applied = await db.manyOrNone( + "select version from schema_migrations order by version", + ); + console.log( + `\n📦 ${applied.length} migration(s) applied: ${applied.map((m) => m.version).join(", ") || "none"}`, + ); + + // Foreign keys are off by default in SQLite; the app relies on cascades, so + // flag it rather than let deletes silently orphan rows. + const [{ foreign_keys: fkEnabled }] = await db.manyOrNone("pragma foreign_keys"); + if (!fkEnabled) { + console.log( + "\nℹ️ foreign_keys pragma is OFF for this connection. libSQL enables it " + + "per-connection; cascade deletes will not fire while it is off.", + ); + } + + await db.close(); + + if (!ok) { + console.error("\n❌ Schema verification failed. Run: pnpm run db:migrate"); process.exit(1); - } finally { - await client.end(); } + + console.log("\n✅ Schema verification passed."); } -verifyMigration().catch((error) => { - console.error('❌ Script failed:', error); +main().catch(async (error) => { + console.error(`\n❌ Verification failed: ${error.message}`); process.exit(1); -}); \ No newline at end of file +}); diff --git a/scripts/verify-rls-policies.js b/scripts/verify-rls-policies.js deleted file mode 100644 index 3b1ec78..0000000 --- a/scripts/verify-rls-policies.js +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env node - -/** - * Verification script for Row Level Security (RLS) policies - * - * This script verifies that: - * 1. RLS is enabled on all required tables - * 2. RLS policies are created for each table - * 3. The helper function exists - * - * Note: This script verifies the RLS configuration exists, but does not test - * the actual isolation behavior (which requires Supabase Auth integration). - * - * Usage: node scripts/verify-rls-policies.js - */ - -import pg from 'pg'; -import { config } from 'dotenv'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const rootDir = join(__dirname, '..'); - -// Load environment variables -config({ path: join(rootDir, '.env') }); -config({ path: join(rootDir, '.env.local') }); - -if (!process.env.DATABASE_URL) { - console.error('❌ DATABASE_URL is not set'); - process.exit(1); -} - -const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); - -// Utilities -const colors = { - reset: '\x1b[0m', - green: '\x1b[32m', - red: '\x1b[31m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - cyan: '\x1b[36m', -}; - -const log = { - success: (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`), - error: (msg) => console.log(`${colors.red}✗${colors.reset} ${msg}`), - info: (msg) => console.log(`${colors.blue}ℹ${colors.reset} ${msg}`), - section: (msg) => console.log(`\n${colors.cyan}${msg}${colors.reset}`), -}; - -/** - * Verify RLS is enabled on all tables - */ -async function verifyRLSEnabled() { - log.section('🔍 Verifying RLS is enabled on all tables...'); - - const tables = [ - 'projects', - 'secrets', - 'workflow_definitions', - 'workflow_runs', - 'workflow_events', - 'audit_log', - ]; - - let allEnabled = true; - - for (const table of tables) { - const result = await pool.query( - `SELECT relrowsecurity - FROM pg_class - WHERE relname = $1 AND relnamespace = 'public'::regnamespace`, - [table] - ); - - if (result.rows.length > 0 && result.rows[0].relrowsecurity) { - log.success(`RLS enabled on ${table}`); - } else { - log.error(`RLS NOT enabled on ${table}`); - allEnabled = false; - } - } - - return allEnabled; -} - -/** - * Verify RLS policies exist for all tables - */ -async function verifyPoliciesExist() { - log.section('📋 Verifying RLS policies exist...'); - - const expectedPolicies = { - projects: 4, // select, insert, update, delete - secrets: 4, - workflow_definitions: 4, - workflow_runs: 4, - workflow_events: 4, - audit_log: 2, // select, insert (no update/delete for audit logs) - }; - - let allPoliciesExist = true; - - for (const [table, expectedCount] of Object.entries(expectedPolicies)) { - const result = await pool.query( - `SELECT COUNT(*) as count - FROM pg_policies - WHERE schemaname = 'public' AND tablename = $1`, - [table] - ); - - const actualCount = parseInt(result.rows[0].count); - - if (actualCount >= expectedCount) { - log.success(`${table}: ${actualCount} policies found (expected ${expectedCount})`); - } else { - log.error(`${table}: ${actualCount} policies found (expected ${expectedCount})`); - allPoliciesExist = false; - } - } - - return allPoliciesExist; -} - -/** - * Verify helper function exists - */ -async function verifyHelperFunction() { - log.section('🔧 Verifying helper function exists...'); - - const result = await pool.query( - `SELECT proname, pronargs - FROM pg_proc - WHERE proname = 'user_project_ids' - AND pronamespace = 'public'::regnamespace` - ); - - if (result.rows.length > 0) { - log.success('Helper function user_project_ids() exists'); - return true; - } else { - log.error('Helper function user_project_ids() NOT found'); - return false; - } -} - -/** - * List all policies for documentation - */ -async function listAllPolicies() { - log.section('📜 Listing all RLS policies...'); - - const result = await pool.query( - `SELECT - schemaname, - tablename, - policyname, - cmd, - qual IS NOT NULL as has_using, - with_check IS NOT NULL as has_with_check - FROM pg_policies - WHERE schemaname = 'public' - ORDER BY tablename, policyname` - ); - - if (result.rows.length === 0) { - log.error('No policies found'); - return; - } - - let currentTable = ''; - for (const row of result.rows) { - if (row.tablename !== currentTable) { - console.log(`\n ${colors.cyan}${row.tablename}:${colors.reset}`); - currentTable = row.tablename; - } - - const cmdStr = row.cmd === '*' ? 'ALL' : row.cmd; - const usingStr = row.has_using ? '✓' : ' '; - const checkStr = row.has_with_check ? '✓' : ' '; - - console.log(` • ${row.policyname}`); - console.log(` Command: ${cmdStr}, USING: ${usingStr}, WITH CHECK: ${checkStr}`); - } -} - -/** - * Main verification runner - */ -async function runVerification() { - console.log(`${colors.cyan} -╔═══════════════════════════════════════════════════════════╗ -║ MeshHook RLS Policy Verification ║ -║ Checking RLS Configuration ║ -╚═══════════════════════════════════════════════════════════╝ -${colors.reset}`); - - try { - const rlsEnabled = await verifyRLSEnabled(); - const policiesExist = await verifyPoliciesExist(); - const helperExists = await verifyHelperFunction(); - await listAllPolicies(); - - log.section('📊 Verification Summary'); - - if (rlsEnabled && policiesExist && helperExists) { - log.success('All RLS policies are correctly configured! ✨'); - log.info(''); - log.info('Note: RLS policies will enforce multi-tenant isolation when users'); - log.info('authenticate through Supabase Auth. The policies filter data by'); - log.info('project_id based on the authenticated user\'s owned projects.'); - process.exit(0); - } else { - log.error('Some RLS configuration issues were found. Please review the output above.'); - process.exit(1); - } - } catch (error) { - log.error(`Verification failed: ${error.message}`); - console.error(error); - process.exit(1); - } finally { - await pool.end(); - } -} - -// Run verification -runVerification(); \ No newline at end of file diff --git a/src/nodes/loop.test.js b/src/nodes/loop.test.js index 23f861c..beba67d 100644 --- a/src/nodes/loop.test.js +++ b/src/nodes/loop.test.js @@ -1,8 +1,12 @@ /** * Loop Node Tests - * Testing Framework: Mocha with Chai + * + * Runner: node:test, with chai assertions. The describe/it imports were missing, + * so this file threw "describe is not defined" and its suite never ran — the + * sibling tests in this directory import them explicitly. */ +import { describe, it } from 'node:test'; import { expect } from 'chai'; import { LoopNode, LoopError, createLoopNode } from './loop.js'; diff --git a/src/queue/dlq-service.js b/src/queue/dlq-service.js index d785430..744f537 100644 --- a/src/queue/dlq-service.js +++ b/src/queue/dlq-service.js @@ -1,66 +1,60 @@ -// DLQService - Dead Letter Queue Management -// Issue #93: Implement DLQ (Dead Letter Queue) -// Handles failed jobs that exceed max retry attempts +// DLQService - dead letter queue management on Turso/libSQL +// Issue #92: Implement dead letter queue +// +// Ported from the pgmq/Supabase implementation. Behaviour is unchanged except +// that browsing the DLQ no longer leases messages: the old code called +// pgmq_read with vt=0, which incremented read_ct on every listing. Inspection +// now uses peek(), so read_ct reflects genuine delivery attempts. + +import { db as sharedDb } from "@meshhook/shared/lib/db.js"; +import { Queue } from "@meshhook/shared/lib/queue.js"; /** - * DLQService class for managing dead letter queue operations - * Provides functionality to move, inspect, replay, and manage failed jobs + * Manages jobs that exhausted their retries, and replaying them once the + * underlying fault is fixed. */ export class DLQService { /** - * Create a DLQService instance - * @param {Object} supabaseClient - Supabase client instance - * @param {string} dlqName - Name of the dead letter queue (default: 'workflow_jobs_dlq') + * @param {object} [db] Database handle (defaults to the shared connection). + * @param {string} [dlqName] Dead letter queue name. */ - constructor(supabaseClient, dlqName = 'workflow_jobs_dlq') { - if (!supabaseClient) { - throw new Error('Supabase client is required'); + constructor(db = sharedDb, dlqName = "workflow_jobs_dlq") { + if (!db) { + throw new Error("Database handle is required"); } - this.client = supabaseClient; + this.db = db; this.dlqName = dlqName; + this.dlq = new Queue({ name: dlqName, db }); + this.mainQueue = new Queue({ name: "workflow_jobs", db }); } /** - * Move a failed job to the dead letter queue - * @param {Object} job - Job object from main queue - * @param {string} errorMessage - Error message describing the failure - * @param {string} errorStack - Optional error stack trace - * @returns {Promise} Result containing dlq_msg_id + * Move a failed job to the dead letter queue. + * @param {Object} job Job from the main queue. + * @param {string} [errorMessage] Failure description. + * @param {string} [errorStack] Optional stack trace. + * @returns {Promise<{dlq_msg_id: number}>} */ async moveToDeadLetter(job, errorMessage = null, errorStack = null) { if (!job || !job.message) { - throw new Error('Invalid job object'); + throw new Error("Invalid job object"); } try { - // Enhance message with DLQ metadata const dlqMessage = { ...job.message, original_msg_id: job.msg_id, moved_to_dlq_at: new Date().toISOString(), - error_message: errorMessage || 'Unknown error', + error_message: errorMessage || "Unknown error", error_stack: errorStack, original_enqueued_at: job.enqueued_at, read_count: job.read_ct || 0, }; - // Send to DLQ using PGMQ - const { data, error } = await this.client.rpc('pgmq_send', { - queue_name: this.dlqName, - msg: dlqMessage, - delay: 0, - }); - - if (error) { - throw new Error(`Failed to move job to DLQ: ${error.message}`); - } - - const dlqMsgId = data; + const dlqMsgId = await this.dlq.send(dlqMessage, 0); - // Archive original message from main queue await this._archiveOriginalJob(job.msg_id); - // Update job tracking await this._updateJobTracking(job.msg_id, { moved_to_dlq_at: new Date().toISOString(), error_message: errorMessage, @@ -74,206 +68,133 @@ export class DLQService { } /** - * List jobs in the dead letter queue - * @param {number} limit - Maximum number of jobs to return (default: 100) - * @returns {Promise} Array of DLQ jobs + * List jobs sitting in the DLQ. Does not lease or hide them. + * @param {number} limit Maximum jobs to return. + * @returns {Promise} */ async listDeadLetterJobs(limit = 100) { try { - const { data, error } = await this.client.rpc('pgmq_read', { - queue_name: this.dlqName, - vt: 0, // No visibility timeout for inspection - qty: limit, - }); - - if (error) { - throw new Error(`Failed to list DLQ jobs: ${error.message}`); - } - - return data || []; + return await this.dlq.peek(limit); } catch (error) { throw new Error(`List DLQ jobs failed: ${error.message}`); } } /** - * Get a specific job from the dead letter queue - * @param {number} dlqMsgId - DLQ message ID - * @returns {Promise} Job object or null if not found + * Fetch one DLQ job by id. + * + * The Postgres version read a page of messages and searched it, so a job + * outside the first page looked missing. This looks the id up directly. + * @returns {Promise} */ async getDeadLetterJob(dlqMsgId) { try { - const { data, error } = await this.client.rpc('pgmq_read', { - queue_name: this.dlqName, - vt: 0, - qty: 1, - }); - - if (error) { - throw new Error(`Failed to get DLQ job: ${error.message}`); - } - - if (!data || data.length === 0) { - return null; - } - - // Find the specific message - const job = data.find((j) => j.msg_id === dlqMsgId); - return job || null; + return await this.dlq.peekOne(dlqMsgId); } catch (error) { throw new Error(`Get DLQ job failed: ${error.message}`); } } /** - * Replay a job from DLQ back to the main queue - * @param {number} dlqMsgId - DLQ message ID to replay - * @param {string} targetQueue - Target queue name (default: 'workflow_jobs') - * @returns {Promise} Result containing new_msg_id + * Replay a job from the DLQ back onto a live queue, resetting its attempt + * counter and stripping the failure metadata. + * @returns {Promise<{new_msg_id: number}>} */ - async replayDeadLetterJob(dlqMsgId, targetQueue = 'workflow_jobs') { + async replayDeadLetterJob(dlqMsgId, targetQueue = "workflow_jobs") { try { - // Get the job from DLQ - const jobs = await this.listDeadLetterJobs(1000); - const job = jobs.find((j) => j.msg_id === dlqMsgId); + const job = await this.dlq.peekOne(dlqMsgId); if (!job) { throw new Error(`Job ${dlqMsgId} not found in DLQ`); } - // Prepare message for replay (reset attempt counter) const replayMessage = { ...job.message, - attempt: 1, // Reset attempt counter + attempt: 1, replayed_from_dlq: true, replayed_at: new Date().toISOString(), original_dlq_msg_id: dlqMsgId, }; - // Remove DLQ-specific metadata delete replayMessage.moved_to_dlq_at; delete replayMessage.error_message; delete replayMessage.error_stack; delete replayMessage.original_msg_id; - // Send to target queue - const { data, error } = await this.client.rpc('pgmq_send', { - queue_name: targetQueue, - msg: replayMessage, - delay: 0, - }); - - if (error) { - throw new Error(`Failed to replay job: ${error.message}`); - } + const target = + targetQueue === this.dlqName ? this.dlq : new Queue({ name: targetQueue, db: this.db }); + const newMsgId = await target.send(replayMessage, 0); - // Delete from DLQ await this.deleteDeadLetterJob(dlqMsgId); - return { new_msg_id: data }; + return { new_msg_id: newMsgId }; } catch (error) { throw new Error(`Replay job failed: ${error.message}`); } } /** - * Delete a job from the dead letter queue - * @param {number} dlqMsgId - DLQ message ID to delete - * @returns {Promise} True if deleted, false otherwise + * Permanently drop a job from the DLQ. + * @returns {Promise} */ async deleteDeadLetterJob(dlqMsgId) { try { - const { data, error } = await this.client.rpc('pgmq_delete', { - queue_name: this.dlqName, - msg_id: dlqMsgId, - }); - - if (error) { - throw new Error(`Failed to delete DLQ job: ${error.message}`); - } - - return data === true; + return await this.dlq.deleteMessage(dlqMsgId); } catch (error) { throw new Error(`Delete DLQ job failed: ${error.message}`); } } /** - * Get DLQ metrics - * @returns {Promise} DLQ metrics + * DLQ depth and message age. + * @returns {Promise} */ async getDLQMetrics() { try { - const { data, error } = await this.client.rpc('get_queue_metrics', { - p_queue_name: this.dlqName, - }); - - if (error) { - throw new Error(`Failed to get DLQ metrics: ${error.message}`); - } - - return data[0] || { - dlq_name: this.dlqName, - total_jobs: 0, - oldest_job_age_seconds: null, - newest_job_age_seconds: null, - }; + return await this.dlq.metrics(); } catch (error) { throw new Error(`Get DLQ metrics failed: ${error.message}`); } } /** - * Purge all jobs from the dead letter queue - * @returns {Promise} Number of jobs purged + * Empty the DLQ. + * @returns {Promise} Jobs removed. */ async purgeDLQ() { try { - const { data, error } = await this.client.rpc('pgmq_purge_queue', { - queue_name: this.dlqName, - }); - - if (error) { - throw new Error(`Failed to purge DLQ: ${error.message}`); - } - - return data || 0; + return await this.dlq.purge(); } catch (error) { throw new Error(`Purge DLQ failed: ${error.message}`); } } /** - * Get jobs grouped by error type - * @returns {Promise} Jobs grouped by error message + * Group DLQ jobs by their error message, for spotting a common root cause. + * @returns {Promise>} */ async getJobsByErrorType() { try { const jobs = await this.listDeadLetterJobs(1000); - // Group by error message - const grouped = jobs.reduce((acc, job) => { - const errorMsg = job.message.error_message || 'Unknown error'; + return jobs.reduce((acc, job) => { + const errorMsg = job.message.error_message || "Unknown error"; if (!acc[errorMsg]) { acc[errorMsg] = []; } acc[errorMsg].push(job); return acc; }, {}); - - return grouped; } catch (error) { throw new Error(`Get jobs by error type failed: ${error.message}`); } } /** - * Replay multiple jobs from DLQ - * @param {Array} dlqMsgIds - Array of DLQ message IDs - * @param {string} targetQueue - Target queue name - * @returns {Promise} Results with success and failure counts + * Replay several jobs, continuing past individual failures. + * @returns {Promise<{success:number, failed:number, errors:Array}>} */ - async replayMultipleJobs(dlqMsgIds, targetQueue = 'workflow_jobs') { + async replayMultipleJobs(dlqMsgIds, targetQueue = "workflow_jobs") { const results = { success: 0, failed: 0, @@ -297,52 +218,55 @@ export class DLQService { } /** - * Archive original job from main queue + * Archive the original message from the main queue. + * + * The worker may already have archived it, so a miss is not an error. * @private - * @param {number} msgId - Original message ID */ async _archiveOriginalJob(msgId) { try { - // Note: This assumes the job is still in the main queue - // In practice, it may already be archived by the worker - await this.client.rpc('pgmq_archive', { - queue_name: 'workflow_jobs', - msg_id: msgId, - }); + await this.mainQueue.archive(msgId); } catch (error) { - // Log but don't fail - job may already be archived - console.warn(`Failed to archive original job ${msgId}:`, error.message); + console.error("Failed to archive original job:", error.message); } } /** - * Update job tracking record + * Patch the job_tracking row for a message. Column names come from a fixed + * allow-list so this cannot become arbitrary SQL. * @private - * @param {number} msgId - Message ID - * @param {Object} updates - Fields to update */ async _updateJobTracking(msgId, updates) { - try { - const { error } = await this.client - .from('job_tracking') - .update(updates) - .eq('msg_id', msgId); + const ALLOWED = new Set([ + "started_at", + "completed_at", + "failed_at", + "moved_to_dlq_at", + "error_message", + "error_stack", + "attempt", + ]); + + const columns = Object.keys(updates).filter((k) => ALLOWED.has(k)); + if (columns.length === 0) return; - if (error) { - console.error('Failed to update job tracking:', error.message); - } + try { + await this.db.none( + `update job_tracking set ${columns.map((c) => `${c} = ?`).join(", ")} + where msg_id = ?`, + [...columns.map((c) => updates[c]), msgId], + ); } catch (error) { - console.error('Failed to update job tracking:', error.message); + console.error("Failed to update job tracking:", error.message); } } } /** - * Create a DLQService instance - * @param {Object} supabaseClient - Supabase client instance - * @param {string} dlqName - Optional DLQ name - * @returns {DLQService} DLQService instance + * @param {object} [db] Database handle. + * @param {string} [dlqName] DLQ name. + * @returns {DLQService} */ -export function createDLQService(supabaseClient, dlqName) { - return new DLQService(supabaseClient, dlqName); -} \ No newline at end of file +export function createDLQService(db, dlqName) { + return new DLQService(db, dlqName); +} diff --git a/src/queue/dlq-service.test.js b/src/queue/dlq-service.test.js index fc177a8..3fa2995 100644 --- a/src/queue/dlq-service.test.js +++ b/src/queue/dlq-service.test.js @@ -1,310 +1,212 @@ -// Test file for DLQService (Dead Letter Queue) -// Testing Framework: Mocha with Chai -// Issue #93: Implement DLQ (Dead Letter Queue) +// DLQService tests — run against a real in-memory libSQL database. +// Issue #92: Implement dead letter queue -import { expect } from 'chai'; -import { DLQService } from './dlq-service.js'; -import { createClient } from '@supabase/supabase-js'; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { QueueService } from "./queue-service.js"; +import { DLQService } from "./dlq-service.js"; +import { createTestDb, seedRun, jobFor } from "./test-helpers.js"; -describe('DLQService', () => { +describe("DLQService", () => { + let db; + let queueService; let dlqService; - let supabaseClient; - - before(() => { - // Initialize Supabase client for testing - const supabaseUrl = process.env.SUPABASE_URL || 'http://localhost:54321'; - const supabaseKey = process.env.SUPABASE_ANON_KEY || 'test-key'; - supabaseClient = createClient(supabaseUrl, supabaseKey); - dlqService = new DLQService(supabaseClient); + let seed; + + /** Enqueue a job, lease it, and hand back the leased job object. */ + async function leaseJob() { + await queueService.enqueue(jobFor(seed)); + return queueService.dequeue(); + } + + beforeEach(async () => { + db = await createTestDb(); + queueService = new QueueService(db); + dlqService = new DLQService(db); + seed = await seedRun(db); }); - describe('constructor', () => { - it('should create a DLQService instance', () => { - expect(dlqService).to.be.instanceOf(DLQService); - }); + afterEach(async () => { + await db.close(); + }); - it('should have default DLQ name', () => { - expect(dlqService.dlqName).to.equal('workflow_jobs_dlq'); + describe("constructor", () => { + it("defaults to workflow_jobs_dlq", () => { + expect(dlqService.dlqName).toBe("workflow_jobs_dlq"); }); - it('should accept custom DLQ name', () => { - const customDLQ = new DLQService(supabaseClient, 'custom_dlq'); - expect(customDLQ.dlqName).to.equal('custom_dlq'); + it("rejects a missing database handle", () => { + expect(() => new DLQService(null)).toThrow(/Database handle is required/); }); }); - describe('moveToDeadLetter', () => { - it('should move failed job to DLQ', async () => { - const job = { - msg_id: 12345, - message: { - run_id: '123e4567-e89b-12d3-a456-426614174000', - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - attempt: 5, - max_attempts: 5, - }, - }; - - const errorMessage = 'Maximum retry attempts exceeded'; - - const result = await dlqService.moveToDeadLetter(job, errorMessage); - - expect(result).to.have.property('dlq_msg_id'); - expect(result.dlq_msg_id).to.be.a('number'); + describe("moveToDeadLetter", () => { + it("rejects an invalid job", async () => { + await expect(dlqService.moveToDeadLetter(null)).rejects.toThrow(/Invalid job object/); + await expect(dlqService.moveToDeadLetter({})).rejects.toThrow(/Invalid job object/); }); - it('should include error information in DLQ message', async () => { - const job = { - msg_id: 12346, - message: { - run_id: '123e4567-e89b-12d3-a456-426614174003', - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - attempt: 5, - }, - }; - - const errorMessage = 'Network timeout'; - const errorStack = 'Error: Network timeout\n at Worker.process'; - - const result = await dlqService.moveToDeadLetter( - job, - errorMessage, - errorStack - ); - - expect(result).to.have.property('dlq_msg_id'); - }); + it("attaches the failure metadata", async () => { + const job = await leaseJob(); + const { dlq_msg_id } = await dlqService.moveToDeadLetter(job, "boom", "at foo()"); - it('should handle missing error message', async () => { - const job = { - msg_id: 12347, - message: { - run_id: '123e4567-e89b-12d3-a456-426614174004', - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }, - }; - - const result = await dlqService.moveToDeadLetter(job); - expect(result).to.have.property('dlq_msg_id'); + const dead = await dlqService.getDeadLetterJob(dlq_msg_id); + expect(dead.message.error_message).toBe("boom"); + expect(dead.message.error_stack).toBe("at foo()"); + expect(dead.message.original_msg_id).toBe(job.msg_id); + expect(dead.message.run_id).toBe(seed.runId); }); - }); - describe('listDeadLetterJobs', () => { - it('should list jobs in DLQ', async () => { - const jobs = await dlqService.listDeadLetterJobs(); + it("defaults the error message when none is given", async () => { + const job = await leaseJob(); + const { dlq_msg_id } = await dlqService.moveToDeadLetter(job); - expect(jobs).to.be.an('array'); - jobs.forEach((job) => { - expect(job).to.have.property('msg_id'); - expect(job).to.have.property('message'); - expect(job.message).to.have.property('moved_to_dlq_at'); - }); + const dead = await dlqService.getDeadLetterJob(dlq_msg_id); + expect(dead.message.error_message).toBe("Unknown error"); }); - it('should limit number of jobs returned', async () => { - const limit = 5; - const jobs = await dlqService.listDeadLetterJobs(limit); + it("archives the original message off the main queue", async () => { + const job = await leaseJob(); + await dlqService.moveToDeadLetter(job, "boom"); - expect(jobs).to.be.an('array'); - expect(jobs.length).to.be.at.most(limit); + expect(await queueService.queue.size()).toBe(0); + const archived = await db.oneOrNone("select * from queue_archive where msg_id = ?", [ + job.msg_id, + ]); + expect(archived).not.toBeNull(); }); - it('should return empty array when DLQ is empty', async () => { - // Create a new DLQ service with unique queue name - const emptyDLQ = new DLQService(supabaseClient, 'empty_dlq_test'); - const jobs = await emptyDLQ.listDeadLetterJobs(); + it("stamps moved_to_dlq_at on the tracking row", async () => { + const job = await leaseJob(); + await dlqService.moveToDeadLetter(job, "boom"); - expect(jobs).to.be.an('array'); - expect(jobs.length).to.equal(0); + const row = await db.one("select * from job_tracking where msg_id = ?", [job.msg_id]); + expect(row.moved_to_dlq_at).toBeTruthy(); + expect(row.error_message).toBe("boom"); }); }); - describe('getDeadLetterJob', () => { - it('should retrieve specific job from DLQ', async () => { - // First, add a job to DLQ - const testJob = { - msg_id: 12348, - message: { - run_id: '123e4567-e89b-12d3-a456-426614174005', - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }, - }; - - const moveResult = await dlqService.moveToDeadLetter(testJob); - const dlqMsgId = moveResult.dlq_msg_id; - - // Then retrieve it - const job = await dlqService.getDeadLetterJob(dlqMsgId); - - if (job) { - expect(job).to.have.property('msg_id'); - expect(job.msg_id).to.equal(dlqMsgId); - expect(job).to.have.property('message'); - } + describe("listDeadLetterJobs", () => { + it("returns an empty list when the DLQ is empty", async () => { + expect(await dlqService.listDeadLetterJobs()).toEqual([]); }); - it('should return null for non-existent job', async () => { - const job = await dlqService.getDeadLetterJob(999999999); - expect(job).to.be.null; - }); - }); + it("does not lease the jobs it lists", async () => { + const job = await leaseJob(); + await dlqService.moveToDeadLetter(job, "boom"); - describe('replayDeadLetterJob', () => { - it('should replay job from DLQ to main queue', async () => { - // First, add a job to DLQ - const testJob = { - msg_id: 12349, - message: { - run_id: '123e4567-e89b-12d3-a456-426614174006', - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }, - }; - - const moveResult = await dlqService.moveToDeadLetter(testJob); - const dlqMsgId = moveResult.dlq_msg_id; - - // Then replay it - const result = await dlqService.replayDeadLetterJob( - dlqMsgId, - 'workflow_jobs' - ); - - expect(result).to.have.property('new_msg_id'); - expect(result.new_msg_id).to.be.a('number'); + await dlqService.listDeadLetterJobs(); + const [again] = await dlqService.listDeadLetterJobs(); + + // Browsing used to increment read_ct via pgmq_read(vt=0); it must not. + expect(again.read_ct).toBe(0); }); - it('should reset attempt counter when replaying', async () => { - const testJob = { - msg_id: 12350, - message: { - run_id: '123e4567-e89b-12d3-a456-426614174007', - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - attempt: 5, - }, - }; - - const moveResult = await dlqService.moveToDeadLetter(testJob); - const result = await dlqService.replayDeadLetterJob( - moveResult.dlq_msg_id, - 'workflow_jobs' - ); - - expect(result).to.have.property('new_msg_id'); + it("honours the limit", async () => { + for (let i = 0; i < 3; i++) { + await dlqService.moveToDeadLetter(await leaseJob(), `err-${i}`); + } + expect(await dlqService.listDeadLetterJobs(2)).toHaveLength(2); }); }); - describe('deleteDeadLetterJob', () => { - it('should delete job from DLQ', async () => { - // First, add a job to DLQ - const testJob = { - msg_id: 12351, - message: { - run_id: '123e4567-e89b-12d3-a456-426614174008', - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }, - }; - - const moveResult = await dlqService.moveToDeadLetter(testJob); - const dlqMsgId = moveResult.dlq_msg_id; - - // Then delete it - const result = await dlqService.deleteDeadLetterJob(dlqMsgId); - expect(result).to.be.true; + describe("getDeadLetterJob", () => { + it("returns null for an unknown id", async () => { + expect(await dlqService.getDeadLetterJob(999999)).toBeNull(); }); - it('should return false for non-existent job', async () => { - const result = await dlqService.deleteDeadLetterJob(999999999); - expect(result).to.be.false; + it("finds a job beyond the first page", async () => { + let last; + for (let i = 0; i < 5; i++) { + last = await dlqService.moveToDeadLetter(await leaseJob(), `err-${i}`); + } + // The Postgres version paged through results and could miss this. + const found = await dlqService.getDeadLetterJob(last.dlq_msg_id); + expect(found?.msg_id).toBe(last.dlq_msg_id); }); }); - describe('getDLQMetrics', () => { - it('should return DLQ metrics', async () => { - const metrics = await dlqService.getDLQMetrics(); + describe("replayDeadLetterJob", () => { + it("puts the job back on the main queue and clears the DLQ entry", async () => { + const job = await leaseJob(); + const { dlq_msg_id } = await dlqService.moveToDeadLetter(job, "boom"); - expect(metrics).to.have.property('dlq_name'); - expect(metrics).to.have.property('total_jobs'); - expect(metrics.dlq_name).to.equal('workflow_jobs_dlq'); - expect(metrics.total_jobs).to.be.a('number'); + const { new_msg_id } = await dlqService.replayDeadLetterJob(dlq_msg_id); + + expect(new_msg_id).toBeTypeOf("number"); + expect(await dlqService.getDeadLetterJob(dlq_msg_id)).toBeNull(); + + const replayed = await queueService.dequeue(); + expect(replayed.msg_id).toBe(new_msg_id); + expect(replayed.message.run_id).toBe(seed.runId); }); - it('should include age information', async () => { - const metrics = await dlqService.getDLQMetrics(); + it("resets the attempt counter and strips failure metadata", async () => { + await queueService.enqueue(jobFor(seed, { attempt: 5 })); + const job = await queueService.dequeue(); + const { dlq_msg_id } = await dlqService.moveToDeadLetter(job, "boom", "stack"); - expect(metrics).to.have.property('oldest_job_age_seconds'); - expect(metrics).to.have.property('newest_job_age_seconds'); + await dlqService.replayDeadLetterJob(dlq_msg_id); + const replayed = await queueService.dequeue(); + + expect(replayed.message.attempt).toBe(1); + expect(replayed.message.replayed_from_dlq).toBe(true); + expect(replayed.message.error_message).toBeUndefined(); + expect(replayed.message.error_stack).toBeUndefined(); + expect(replayed.message.moved_to_dlq_at).toBeUndefined(); }); - }); - describe('purgeDLQ', () => { - it('should purge all jobs from DLQ', async () => { - // Add some test jobs first - const testJob = { - msg_id: 12352, - message: { - run_id: '123e4567-e89b-12d3-a456-426614174009', - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }, - }; - - await dlqService.moveToDeadLetter(testJob); - - // Then purge - const result = await dlqService.purgeDLQ(); - expect(result).to.be.a('number'); - expect(result).to.be.at.least(0); + it("throws for an unknown id", async () => { + await expect(dlqService.replayDeadLetterJob(999999)).rejects.toThrow(/not found in DLQ/); }); }); - describe('error handling', () => { - it('should handle invalid job data gracefully', async () => { - try { - await dlqService.moveToDeadLetter(null); - expect.fail('Should have thrown an error'); - } catch (error) { - expect(error).to.be.instanceOf(Error); + describe("replayMultipleJobs", () => { + it("counts successes and failures separately", async () => { + const ids = []; + for (let i = 0; i < 2; i++) { + const { dlq_msg_id } = await dlqService.moveToDeadLetter(await leaseJob(), `err-${i}`); + ids.push(dlq_msg_id); } + + const result = await dlqService.replayMultipleJobs([...ids, 999999]); + + expect(result.success).toBe(2); + expect(result.failed).toBe(1); + expect(result.errors[0].msg_id).toBe(999999); }); + }); - it('should handle database errors gracefully', async () => { - const badClient = createClient('http://invalid-url', 'invalid-key'); - const badDLQ = new DLQService(badClient); + describe("getJobsByErrorType", () => { + it("groups jobs by their error message", async () => { + await dlqService.moveToDeadLetter(await leaseJob(), "timeout"); + await dlqService.moveToDeadLetter(await leaseJob(), "timeout"); + await dlqService.moveToDeadLetter(await leaseJob(), "bad gateway"); - try { - await badDLQ.listDeadLetterJobs(); - expect.fail('Should have thrown an error'); - } catch (error) { - expect(error).to.be.instanceOf(Error); - } + const grouped = await dlqService.getJobsByErrorType(); + + expect(Object.keys(grouped).sort()).toEqual(["bad gateway", "timeout"]); + expect(grouped.timeout).toHaveLength(2); }); }); - describe('integration with job tracking', () => { - it('should update job tracking when moving to DLQ', async () => { - const testJob = { - msg_id: 12353, - message: { - run_id: '123e4567-e89b-12d3-a456-426614174010', - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }, - }; - - const result = await dlqService.moveToDeadLetter( - testJob, - 'Test error message' - ); - - expect(result).to.have.property('dlq_msg_id'); - // Job tracking should be updated with moved_to_dlq_at timestamp + describe("purgeDLQ", () => { + it("empties the DLQ without touching the main queue", async () => { + await dlqService.moveToDeadLetter(await leaseJob(), "boom"); + await queueService.enqueue(jobFor(seed)); + + expect(await dlqService.purgeDLQ()).toBe(1); + expect(await dlqService.listDeadLetterJobs()).toEqual([]); + expect(await queueService.queue.size()).toBe(1); + }); + }); + + describe("getDLQMetrics", () => { + it("reports the DLQ depth", async () => { + await dlqService.moveToDeadLetter(await leaseJob(), "boom"); + + const metrics = await dlqService.getDLQMetrics(); + expect(metrics.queue_name).toBe("workflow_jobs_dlq"); + expect(metrics.queue_length).toBe(1); }); }); -}); \ No newline at end of file +}); diff --git a/src/queue/integration.test.js b/src/queue/integration.test.js index a118ecd..416f73b 100644 --- a/src/queue/integration.test.js +++ b/src/queue/integration.test.js @@ -1,330 +1,171 @@ -// Integration Tests for Queue System -// Testing Framework: Mocha with Chai -// Tests the complete queue system including retry and DLQ functionality - -import { expect } from 'chai'; -import { createClient } from '@supabase/supabase-js'; -import { - QueueService, - DLQService, - RetryStrategy, - Worker, - createJobHandler, -} from './index.js'; - -describe('Queue System Integration Tests', () => { - let supabaseClient; +// Queue system integration tests. +// +// Exercises QueueService, DLQService, RetryStrategy and Worker together against +// a real in-memory libSQL database. The previous version of this file required +// a running Supabase stack on localhost:54321 and was skipped in practice. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { QueueService, DLQService, RetryStrategy, Worker } from "./index.js"; +import { createTestDb, seedRun, jobFor } from "./test-helpers.js"; + +describe("Queue system integration", () => { + let db; let queueService; let dlqService; - let retryStrategy; - - before(() => { - const supabaseUrl = process.env.SUPABASE_URL || 'http://localhost:54321'; - const supabaseKey = process.env.SUPABASE_ANON_KEY || 'test-key'; - supabaseClient = createClient(supabaseUrl, supabaseKey); - - queueService = new QueueService(supabaseClient); - dlqService = new DLQService(supabaseClient); - retryStrategy = new RetryStrategy({ - baseDelayMs: 100, - maxDelayMs: 1000, - maxAttempts: 3, - }); - }); - - describe('End-to-End Job Processing', () => { - it('should successfully process a job from enqueue to completion', async () => { - const runId = '123e4567-e89b-12d3-a456-426614174100'; - let jobProcessed = false; - - // Enqueue a job - const enqueueResult = await queueService.enqueue({ - run_id: runId, - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); - - expect(enqueueResult).to.have.property('msg_id'); - - // Create a worker to process the job - const jobHandler = createJobHandler(async (message) => { - expect(message.run_id).to.equal(runId); - jobProcessed = true; - }); - - const worker = new Worker({ - supabaseClient, - jobHandler, - pollIntervalMs: 100, - retryConfig: { - baseDelayMs: 100, - maxDelayMs: 1000, - maxAttempts: 3, - }, - }); - - await worker.start(); - - // Wait for job to be processed - await new Promise((resolve) => setTimeout(resolve, 500)); - - await worker.stop(); - - expect(jobProcessed).to.be.true; - expect(worker.getStats().succeeded).to.be.at.least(1); - }); - - it('should retry failed jobs with exponential backoff', async () => { - const runId = '123e4567-e89b-12d3-a456-426614174101'; - let attemptCount = 0; + let seed; - // Enqueue a job - await queueService.enqueue({ - run_id: runId, - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); - - // Create a worker that fails twice then succeeds - const jobHandler = createJobHandler(async (message) => { - attemptCount++; - if (attemptCount < 3) { - throw new Error('Simulated failure'); - } - // Success on third attempt - }); - - const worker = new Worker({ - supabaseClient, - jobHandler, - pollIntervalMs: 100, - retryConfig: { - baseDelayMs: 100, - maxDelayMs: 1000, - maxAttempts: 3, - }, - }); - - await worker.start(); - - // Wait for retries to complete - await new Promise((resolve) => setTimeout(resolve, 2000)); - - await worker.stop(); - - expect(attemptCount).to.equal(3); - expect(worker.getStats().retried).to.be.at.least(2); - expect(worker.getStats().succeeded).to.be.at.least(1); - }); - - it('should move job to DLQ after max retry attempts', async () => { - const runId = '123e4567-e89b-12d3-a456-426614174102'; - - // Enqueue a job - await queueService.enqueue({ - run_id: runId, - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); - - // Create a worker that always fails - const jobHandler = createJobHandler(async () => { - throw new Error('Permanent failure'); - }); - - const worker = new Worker({ - supabaseClient, - jobHandler, - pollIntervalMs: 100, - retryConfig: { - baseDelayMs: 100, - maxDelayMs: 1000, - maxAttempts: 3, - }, - }); + beforeEach(async () => { + db = await createTestDb(); + queueService = new QueueService(db); + dlqService = new DLQService(db); + seed = await seedRun(db); + }); - await worker.start(); + afterEach(async () => { + await db.close(); + }); - // Wait for all retries and DLQ move - await new Promise((resolve) => setTimeout(resolve, 2000)); + describe("happy path", () => { + it("carries a job from enqueue through acknowledgement", async () => { + const { msg_id } = await queueService.enqueue(jobFor(seed)); - await worker.stop(); + const job = await queueService.dequeue(); + expect(job.msg_id).toBe(msg_id); - expect(worker.getStats().movedToDLQ).to.be.at.least(1); + expect(await queueService.acknowledge(msg_id)).toBe(true); + expect(await queueService.queue.size()).toBe(0); - // Verify job is in DLQ - const dlqJobs = await dlqService.listDeadLetterJobs(); - const movedJob = dlqJobs.find((j) => j.message.run_id === runId); - expect(movedJob).to.exist; - expect(movedJob.message).to.have.property('error_message'); + const tracking = await db.one("select * from job_tracking where msg_id = ?", [msg_id]); + expect(tracking.started_at).toBeTruthy(); + expect(tracking.completed_at).toBeTruthy(); }); }); - describe('DLQ Replay Functionality', () => { - it('should replay job from DLQ back to main queue', async () => { - const runId = '123e4567-e89b-12d3-a456-426614174103'; + describe("retry then dead-letter", () => { + it("retries up to the limit, then moves the job to the DLQ", async () => { + const retryStrategy = new RetryStrategy({ baseDelayMs: 1, maxDelayMs: 5, maxAttempts: 3 }); - // Create a job that will fail and move to DLQ - await queueService.enqueue({ - run_id: runId, - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); + await queueService.enqueue(jobFor(seed, { max_attempts: 3 })); - const jobHandler = createJobHandler(async () => { - throw new Error('Initial failure'); - }); + let attempts = 0; + let lastJob = null; - const worker = new Worker({ - supabaseClient, - jobHandler, - pollIntervalMs: 100, - retryConfig: { - baseDelayMs: 100, - maxDelayMs: 1000, - maxAttempts: 2, - }, - }); + // Each pass leases the job, "fails", and requeues it immediately. + for (let i = 0; i < 3; i++) { + const job = await queueService.dequeue(30); + expect(job).not.toBeNull(); + attempts++; + lastJob = job; - await worker.start(); - await new Promise((resolve) => setTimeout(resolve, 1500)); - await worker.stop(); + if (retryStrategy.canRetry(attempts)) { + await queueService.requeue(job.msg_id, 0); + } + } - // Find the job in DLQ - const dlqJobs = await dlqService.listDeadLetterJobs(); - const dlqJob = dlqJobs.find((j) => j.message.run_id === runId); + expect(attempts).toBe(3); + expect(retryStrategy.canRetry(attempts)).toBe(false); - if (dlqJob) { - // Replay the job - const replayResult = await dlqService.replayDeadLetterJob( - dlqJob.msg_id - ); - expect(replayResult).to.have.property('new_msg_id'); + const { dlq_msg_id } = await dlqService.moveToDeadLetter(lastJob, "exhausted retries"); - // Verify job is back in main queue - const metrics = await queueService.getQueueMetrics(); - expect(metrics.queue_length).to.be.at.least(0); - } + expect(await queueService.queue.size()).toBe(0); + const dead = await dlqService.getDeadLetterJob(dlq_msg_id); + expect(dead.message.error_message).toBe("exhausted retries"); + expect(dead.message.read_count).toBe(3); }); - }); - describe('Retry Strategy Integration', () => { - it('should calculate correct delays for retry attempts', () => { - const delays = []; - for (let attempt = 1; attempt <= 5; attempt++) { - const delay = retryStrategy.getNextDelay(attempt); - delays.push(delay); - } + it("replays a dead-lettered job back onto the main queue", async () => { + await queueService.enqueue(jobFor(seed)); + const job = await queueService.dequeue(); + const { dlq_msg_id } = await dlqService.moveToDeadLetter(job, "downstream 500"); - // Verify delays are increasing (with jitter tolerance) - expect(delays[1]).to.be.greaterThan(delays[0] * 0.5); - expect(delays[2]).to.be.greaterThan(delays[1] * 0.5); - }); + await dlqService.replayDeadLetterJob(dlq_msg_id); - it('should respect max delay limit', () => { - const strategy = new RetryStrategy({ - baseDelayMs: 1000, - maxDelayMs: 5000, - maxAttempts: 10, - }); - - const delay = strategy.getNextDelay(10); - expect(delay).to.be.at.most(5000); + const replayed = await queueService.dequeue(); + expect(replayed.message.run_id).toBe(seed.runId); + expect(replayed.message.attempt).toBe(1); }); }); - describe('Queue Metrics and Monitoring', () => { - it('should provide accurate queue metrics', async () => { - // Enqueue some test jobs - for (let i = 0; i < 3; i++) { - await queueService.enqueue({ - run_id: `123e4567-e89b-12d3-a456-42661417410${i}`, - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); + describe("concurrent consumers", () => { + it("never hands the same message to two readers", async () => { + for (let i = 0; i < 10; i++) { + await queueService.enqueue(jobFor(seed, { metadata: { n: i } })); } - const metrics = await queueService.getQueueMetrics(); + const consumers = Array.from({ length: 4 }, () => new QueueService(db)); + const claimed = []; - expect(metrics).to.have.property('queue_name'); - expect(metrics).to.have.property('queue_length'); - expect(metrics.queue_length).to.be.at.least(0); - }); - - it('should provide DLQ metrics', async () => { - const metrics = await dlqService.getDLQMetrics(); + // Drain the queue from several consumers; a lease must be exclusive. + let job; + do { + const results = await Promise.all(consumers.map((c) => c.dequeue(30))); + const found = results.filter(Boolean); + claimed.push(...found.map((j) => j.msg_id)); + job = found.length > 0 ? found[0] : null; + } while (job); - expect(metrics).to.have.property('dlq_name'); - expect(metrics).to.have.property('total_jobs'); + expect(claimed).toHaveLength(10); + expect(new Set(claimed).size).toBe(10); }); }); - describe('Concurrent Job Processing', () => { - it('should handle multiple jobs concurrently', async () => { - const jobCount = 5; - const processedJobs = new Set(); - - // Enqueue multiple jobs - for (let i = 0; i < jobCount; i++) { - await queueService.enqueue({ - run_id: `concurrent-${i}`, - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); - } + describe("Worker", () => { + it("requires a job handler", () => { + expect(() => new Worker({ db })).toThrow(/Job handler function is required/); + }); - const jobHandler = createJobHandler(async (message) => { - processedJobs.add(message.run_id); - await new Promise((resolve) => setTimeout(resolve, 100)); - }); + it("processes a job and acknowledges it", async () => { + const handled = []; const worker = new Worker({ - supabaseClient, - jobHandler, - pollIntervalMs: 50, + db, + pollIntervalMs: 5, + jobHandler: async (message) => { + handled.push(message); + }, }); + await queueService.enqueue(jobFor(seed)); await worker.start(); - await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Give the poll loop room to pick the job up. + await waitFor(() => handled.length === 1); await worker.stop(); - expect(processedJobs.size).to.be.at.least(1); + expect(handled[0].run_id).toBe(seed.runId); + expect(await queueService.queue.size()).toBe(0); + expect(worker.stats.succeeded).toBe(1); }); - }); - - describe('Error Handling', () => { - it('should handle malformed job data gracefully', async () => { - try { - await queueService.enqueue({ - // Missing required fields - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - }); - expect.fail('Should have thrown an error'); - } catch (error) { - expect(error.message).to.include('run_id'); - } - }); - - it('should handle worker errors without crashing', async () => { - const jobHandler = createJobHandler(async () => { - throw new Error('Critical error'); - }); + it("does not acknowledge a job whose handler throws", async () => { const worker = new Worker({ - supabaseClient, - jobHandler, - pollIntervalMs: 100, - retryConfig: { - maxAttempts: 1, + db, + pollIntervalMs: 5, + retryConfig: { baseDelayMs: 1, maxDelayMs: 2 }, + jobHandler: async () => { + throw new Error("handler exploded"); }, }); + await queueService.enqueue(jobFor(seed)); await worker.start(); - await new Promise((resolve) => setTimeout(resolve, 500)); + + await waitFor(() => worker.stats.failed > 0 || worker.stats.retried > 0); await worker.stop(); - // Worker should still be functional - expect(worker.getStats().processed).to.be.at.least(0); + // The message must survive for redelivery rather than being dropped. + const tracking = await db.one("select * from job_tracking where run_id = ?", [seed.runId]); + expect(tracking.completed_at).toBeNull(); }); }); -}); \ No newline at end of file +}); + +/** Poll `predicate` until it holds or the timeout lapses. */ +async function waitFor(predicate, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error("Timed out waiting for condition"); +} diff --git a/src/queue/queue-service.js b/src/queue/queue-service.js index 8a7b602..76c771d 100644 --- a/src/queue/queue-service.js +++ b/src/queue/queue-service.js @@ -1,45 +1,50 @@ -// QueueService - PGMQ Queue Management +// QueueService - job queue management on Turso/libSQL // Issue #91: Implement job enqueue/dequeue -// Provides interface for enqueuing, dequeuing, and managing workflow jobs +// +// Previously this drove pgmq through Supabase RPC calls. It now sits on the +// SQLite-backed Queue in @meshhook/shared/lib/queue.js. The public methods and +// their return shapes are unchanged; the constructor takes a database handle +// where it used to take a Supabase client. + +import { db as sharedDb } from "@meshhook/shared/lib/db.js"; +import { Queue } from "@meshhook/shared/lib/queue.js"; /** - * QueueService class for managing PGMQ queues - * Handles job enqueue, dequeue, acknowledgment, and monitoring + * QueueService manages enqueue, dequeue, acknowledgment and monitoring of + * workflow jobs, and mirrors each job's lifecycle into job_tracking. */ export class QueueService { /** - * Create a QueueService instance - * @param {Object} supabaseClient - Supabase client instance - * @param {string} queueName - Name of the queue (default: 'workflow_jobs') + * @param {object} [db] Database handle (defaults to the shared connection). + * @param {string} [queueName] Queue to operate on. */ - constructor(supabaseClient, queueName = 'workflow_jobs') { - if (!supabaseClient) { - throw new Error('Supabase client is required'); + constructor(db = sharedDb, queueName = "workflow_jobs") { + if (!db) { + throw new Error("Database handle is required"); } - this.client = supabaseClient; + this.db = db; this.queueName = queueName; + this.queue = new Queue({ name: queueName, db }); } /** - * Enqueue a job to the queue - * @param {Object} jobData - Job data containing run_id, workflow_id, project_id, etc. - * @param {number} delaySeconds - Optional delay in seconds before job becomes visible - * @returns {Promise} Result containing msg_id + * Enqueue a job. + * @param {Object} jobData Must include run_id, workflow_id and project_id. + * @param {number} delaySeconds Delay before the job becomes visible. + * @returns {Promise<{msg_id: number}>} */ async enqueue(jobData, delaySeconds = 0) { - // Validate required fields if (!jobData.run_id) { - throw new Error('Job data must include run_id'); + throw new Error("Job data must include run_id"); } if (!jobData.workflow_id) { - throw new Error('Job data must include workflow_id'); + throw new Error("Job data must include workflow_id"); } if (!jobData.project_id) { - throw new Error('Job data must include project_id'); + throw new Error("Job data must include project_id"); } try { - // Prepare job payload const payload = { run_id: jobData.run_id, workflow_id: jobData.workflow_id, @@ -50,57 +55,36 @@ export class QueueService { metadata: jobData.metadata || {}, }; - // Use PGMQ send function - const { data, error } = await this.client.rpc('pgmq_send', { - queue_name: this.queueName, - msg: payload, - delay: delaySeconds, - }); - - if (error) { - throw new Error(`Failed to enqueue job: ${error.message}`); - } + const msgId = await this.queue.send(payload, delaySeconds); - // Track job in job_tracking table await this._trackJob({ - msg_id: data, + msg_id: msgId, run_id: jobData.run_id, queue_name: this.queueName, attempt: payload.attempt, max_attempts: payload.max_attempts, }); - return { msg_id: data }; + return { msg_id: msgId }; } catch (error) { throw new Error(`Enqueue failed: ${error.message}`); } } /** - * Dequeue a job from the queue - * @param {number} vtSeconds - Visibility timeout in seconds (default: 30) - * @returns {Promise} Job object or null if queue is empty + * Lease the next available job. + * @param {number} vtSeconds Visibility timeout. + * @returns {Promise} The job, or null when the queue is empty. */ async dequeue(vtSeconds = 30) { try { - const { data, error } = await this.client.rpc('pgmq_read', { - queue_name: this.queueName, - vt: vtSeconds, - qty: 1, - }); - - if (error) { - throw new Error(`Failed to dequeue job: ${error.message}`); - } - - // PGMQ returns array, get first item - if (!data || data.length === 0) { + const messages = await this.queue.read(vtSeconds, 1); + if (messages.length === 0) { return null; } - const job = data[0]; + const job = messages[0]; - // Update job tracking with started_at timestamp await this._updateJobTracking(job.msg_id, { started_at: new Date().toISOString(), }); @@ -118,151 +102,135 @@ export class QueueService { } /** - * Acknowledge and delete a processed job - * @param {number} msgId - Message ID to acknowledge - * @returns {Promise} True if acknowledged, false otherwise + * Acknowledge a processed job, removing it from the queue. + * @returns {Promise} */ async acknowledge(msgId) { try { - const { data, error } = await this.client.rpc('pgmq_delete', { - queue_name: this.queueName, - msg_id: msgId, - }); - - if (error) { - throw new Error(`Failed to acknowledge job: ${error.message}`); - } + const deleted = await this.queue.deleteMessage(msgId); - // Update job tracking with completed_at timestamp await this._updateJobTracking(msgId, { completed_at: new Date().toISOString(), }); - return data === true; + return deleted; } catch (error) { throw new Error(`Acknowledge failed: ${error.message}`); } } /** - * Archive a job (move to archive table) - * @param {number} msgId - Message ID to archive - * @returns {Promise} True if archived, false otherwise + * Move a job to the archive table. + * @returns {Promise} */ async archiveJob(msgId) { try { - const { data, error } = await this.client.rpc('pgmq_archive', { - queue_name: this.queueName, - msg_id: msgId, - }); - - if (error) { - throw new Error(`Failed to archive job: ${error.message}`); - } - - return data === true; + return await this.queue.archive(msgId); } catch (error) { throw new Error(`Archive failed: ${error.message}`); } } /** - * Get queue metrics - * @returns {Promise} Queue metrics including length and age + * Return a leased job to the queue after `delaySeconds`, so a retry does not + * have to wait out the full visibility timeout. + * @returns {Promise} */ - async getQueueMetrics() { + async requeue(msgId, delaySeconds = 0) { try { - const { data, error } = await this.client.rpc('get_queue_metrics', { - p_queue_name: this.queueName, - }); - - if (error) { - throw new Error(`Failed to get queue metrics: ${error.message}`); - } + return await this.queue.setVisibilityTimeout(msgId, delaySeconds); + } catch (error) { + throw new Error(`Requeue failed: ${error.message}`); + } + } - return data[0] || { - queue_name: this.queueName, - queue_length: 0, - oldest_msg_age_seconds: null, - newest_msg_age_seconds: null, - }; + /** + * Queue depth and message age. + * @returns {Promise} + */ + async getQueueMetrics() { + try { + return await this.queue.metrics(); } catch (error) { throw new Error(`Get metrics failed: ${error.message}`); } } /** - * Purge all messages from the queue - * @returns {Promise} Number of messages purged + * Remove every message from the queue. + * @returns {Promise} Number of messages purged. */ async purgeQueue() { try { - const { data, error } = await this.client.rpc('pgmq_purge_queue', { - queue_name: this.queueName, - }); - - if (error) { - throw new Error(`Failed to purge queue: ${error.message}`); - } - - return data || 0; + return await this.queue.purge(); } catch (error) { throw new Error(`Purge failed: ${error.message}`); } } /** - * Track job in job_tracking table + * Insert the job_tracking row for a newly enqueued job. + * + * Tracking is observability, not correctness: a failure here is logged and + * swallowed so it cannot fail the enqueue that already succeeded. * @private - * @param {Object} trackingData - Job tracking data */ async _trackJob(trackingData) { try { - const { error } = await this.client.from('job_tracking').insert({ - msg_id: trackingData.msg_id, - run_id: trackingData.run_id, - queue_name: trackingData.queue_name, - attempt: trackingData.attempt, - max_attempts: trackingData.max_attempts, - }); - - if (error) { - // Log error but don't fail the enqueue operation - console.error('Failed to track job:', error.message); - } + await this.db.none( + `insert into job_tracking (msg_id, run_id, queue_name, attempt, max_attempts) + values (?, ?, ?, ?, ?)`, + [ + trackingData.msg_id, + trackingData.run_id, + trackingData.queue_name, + trackingData.attempt, + trackingData.max_attempts, + ], + ); } catch (error) { - console.error('Failed to track job:', error.message); + console.error("Failed to track job:", error.message); } } /** - * Update job tracking record + * Patch the job_tracking row for a message. + * + * Column names come from a fixed allow-list rather than straight from the + * caller, so this cannot be turned into arbitrary SQL. * @private - * @param {number} msgId - Message ID - * @param {Object} updates - Fields to update */ async _updateJobTracking(msgId, updates) { - try { - const { error } = await this.client - .from('job_tracking') - .update(updates) - .eq('msg_id', msgId); + const ALLOWED = new Set([ + "started_at", + "completed_at", + "failed_at", + "moved_to_dlq_at", + "error_message", + "error_stack", + "attempt", + ]); + + const columns = Object.keys(updates).filter((k) => ALLOWED.has(k)); + if (columns.length === 0) return; - if (error) { - console.error('Failed to update job tracking:', error.message); - } + try { + await this.db.none( + `update job_tracking set ${columns.map((c) => `${c} = ?`).join(", ")} + where msg_id = ?`, + [...columns.map((c) => updates[c]), msgId], + ); } catch (error) { - console.error('Failed to update job tracking:', error.message); + console.error("Failed to update job tracking:", error.message); } } } /** - * Create a QueueService instance - * @param {Object} supabaseClient - Supabase client instance - * @param {string} queueName - Optional queue name - * @returns {QueueService} QueueService instance + * @param {object} [db] Database handle. + * @param {string} [queueName] Queue name. + * @returns {QueueService} */ -export function createQueueService(supabaseClient, queueName) { - return new QueueService(supabaseClient, queueName); -} \ No newline at end of file +export function createQueueService(db, queueName) { + return new QueueService(db, queueName); +} diff --git a/src/queue/queue-service.test.js b/src/queue/queue-service.test.js index 317f988..45c244b 100644 --- a/src/queue/queue-service.test.js +++ b/src/queue/queue-service.test.js @@ -1,224 +1,196 @@ -// Test file for QueueService -// Testing Framework: Mocha with Chai +// QueueService tests — run against a real in-memory libSQL database. // Issue #91: Implement job enqueue/dequeue -import { expect } from 'chai'; -import { QueueService } from './queue-service.js'; -import { createClient } from '@supabase/supabase-js'; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { QueueService } from "./queue-service.js"; +import { createTestDb, seedRun, jobFor } from "./test-helpers.js"; -describe('QueueService', () => { +describe("QueueService", () => { + let db; let queueService; - let supabaseClient; - - before(() => { - // Initialize Supabase client for testing - const supabaseUrl = process.env.SUPABASE_URL || 'http://localhost:54321'; - const supabaseKey = process.env.SUPABASE_ANON_KEY || 'test-key'; - supabaseClient = createClient(supabaseUrl, supabaseKey); - queueService = new QueueService(supabaseClient); + let seed; + + beforeEach(async () => { + db = await createTestDb(); + queueService = new QueueService(db); + seed = await seedRun(db); + }); + + afterEach(async () => { + await db.close(); }); - describe('constructor', () => { - it('should create a QueueService instance', () => { - expect(queueService).to.be.instanceOf(QueueService); + describe("constructor", () => { + it("uses workflow_jobs by default", () => { + expect(queueService.queueName).toBe("workflow_jobs"); }); - it('should have default queue name', () => { - expect(queueService.queueName).to.equal('workflow_jobs'); + it("accepts a custom queue name", () => { + expect(new QueueService(db, "custom_queue").queueName).toBe("custom_queue"); }); - it('should accept custom queue name', () => { - const customQueue = new QueueService(supabaseClient, 'custom_queue'); - expect(customQueue.queueName).to.equal('custom_queue'); + it("rejects a missing database handle", () => { + expect(() => new QueueService(null)).toThrow(/Database handle is required/); }); }); - describe('enqueue', () => { - it('should enqueue a job with run_id', async () => { - const runId = '123e4567-e89b-12d3-a456-426614174000'; - const workflowId = '123e4567-e89b-12d3-a456-426614174001'; - const projectId = '123e4567-e89b-12d3-a456-426614174002'; + describe("enqueue", () => { + it("returns a message id", async () => { + const { msg_id } = await queueService.enqueue(jobFor(seed)); + expect(msg_id).toBeTypeOf("number"); + expect(msg_id).toBeGreaterThan(0); + }); - const result = await queueService.enqueue({ - run_id: runId, - workflow_id: workflowId, - project_id: projectId, - }); + it("records the job in job_tracking", async () => { + const { msg_id } = await queueService.enqueue(jobFor(seed, { max_attempts: 3 })); - expect(result).to.have.property('msg_id'); - expect(result.msg_id).to.be.a('number'); + const row = await db.one("select * from job_tracking where msg_id = ?", [msg_id]); + expect(row.run_id).toBe(seed.runId); + expect(row.queue_name).toBe("workflow_jobs"); + expect(row.attempt).toBe(1); + expect(row.max_attempts).toBe(3); }); - it('should enqueue a job with delay', async () => { - const runId = '123e4567-e89b-12d3-a456-426614174003'; - const delaySeconds = 60; + it.each(["run_id", "workflow_id", "project_id"])("requires %s", async (field) => { + const job = jobFor(seed); + delete job[field]; + await expect(queueService.enqueue(job)).rejects.toThrow(new RegExp(field)); + }); - const result = await queueService.enqueue( - { - run_id: runId, - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }, - delaySeconds - ); + it("hides a delayed job until its delay elapses", async () => { + await queueService.enqueue(jobFor(seed), 60); + expect(await queueService.dequeue()).toBeNull(); + }); + }); - expect(result).to.have.property('msg_id'); - expect(result.msg_id).to.be.a('number'); + describe("dequeue", () => { + it("returns null on an empty queue", async () => { + expect(await queueService.dequeue()).toBeNull(); }); - it('should throw error if run_id is missing', async () => { - try { - await queueService.enqueue({ - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); - expect.fail('Should have thrown an error'); - } catch (error) { - expect(error.message).to.include('run_id'); - } + it("returns the enqueued payload", async () => { + const { msg_id } = await queueService.enqueue(jobFor(seed)); + const job = await queueService.dequeue(); + + expect(job.msg_id).toBe(msg_id); + expect(job.message.run_id).toBe(seed.runId); + expect(job.read_ct).toBe(1); }); - it('should include metadata in job payload', async () => { - const runId = '123e4567-e89b-12d3-a456-426614174004'; - const metadata = { source: 'webhook', priority: 'high' }; + it("hides a leased job from the next reader", async () => { + await queueService.enqueue(jobFor(seed)); + await queueService.dequeue(30); + expect(await queueService.dequeue(30)).toBeNull(); + }); - const result = await queueService.enqueue({ - run_id: runId, - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - metadata, - }); + it("redelivers once the visibility timeout lapses", async () => { + await queueService.enqueue(jobFor(seed)); + // A zero-second lease expires immediately, standing in for a dead worker. + const first = await queueService.dequeue(0); + const second = await queueService.dequeue(30); - expect(result).to.have.property('msg_id'); + expect(second).not.toBeNull(); + expect(second.msg_id).toBe(first.msg_id); + expect(second.read_ct).toBe(2); }); - }); - describe('dequeue', () => { - it('should dequeue a job from the queue', async () => { - // First enqueue a job - const runId = '123e4567-e89b-12d3-a456-426614174005'; - await queueService.enqueue({ - run_id: runId, - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); - - // Then dequeue it - const job = await queueService.dequeue(); + it("serves messages in FIFO order", async () => { + const a = await queueService.enqueue(jobFor(seed, { metadata: { n: 1 } })); + const b = await queueService.enqueue(jobFor(seed, { metadata: { n: 2 } })); - if (job) { - expect(job).to.have.property('msg_id'); - expect(job).to.have.property('message'); - expect(job.message).to.have.property('run_id'); - } + expect((await queueService.dequeue()).msg_id).toBe(a.msg_id); + expect((await queueService.dequeue()).msg_id).toBe(b.msg_id); }); - it('should return null when queue is empty', async () => { - // Create a new queue service with a unique queue name - const emptyQueue = new QueueService(supabaseClient, 'empty_test_queue'); - const job = await emptyQueue.dequeue(); - expect(job).to.be.null; + it("stamps started_at on the tracking row", async () => { + const { msg_id } = await queueService.enqueue(jobFor(seed)); + await queueService.dequeue(); + + const row = await db.one("select started_at from job_tracking where msg_id = ?", [msg_id]); + expect(row.started_at).toBeTruthy(); }); + }); - it('should use custom visibility timeout', async () => { - const runId = '123e4567-e89b-12d3-a456-426614174006'; - await queueService.enqueue({ - run_id: runId, - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); + describe("acknowledge", () => { + it("removes the message and stamps completed_at", async () => { + const { msg_id } = await queueService.enqueue(jobFor(seed)); + await queueService.dequeue(); - const vtSeconds = 60; - const job = await queueService.dequeue(vtSeconds); + expect(await queueService.acknowledge(msg_id)).toBe(true); + expect(await queueService.queue.size()).toBe(0); - if (job) { - expect(job).to.have.property('msg_id'); - } + const row = await db.one("select completed_at from job_tracking where msg_id = ?", [msg_id]); + expect(row.completed_at).toBeTruthy(); + }); + + it("reports false for an unknown message", async () => { + expect(await queueService.acknowledge(999999)).toBe(false); }); }); - describe('acknowledge', () => { - it('should acknowledge and delete a processed job', async () => { - // Enqueue and dequeue a job - const runId = '123e4567-e89b-12d3-a456-426614174007'; - await queueService.enqueue({ - run_id: runId, - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); + describe("archiveJob", () => { + it("moves the message to queue_archive keeping its id", async () => { + const { msg_id } = await queueService.enqueue(jobFor(seed)); - const job = await queueService.dequeue(); - if (job) { - const result = await queueService.acknowledge(job.msg_id); - expect(result).to.be.true; - } + expect(await queueService.archiveJob(msg_id)).toBe(true); + expect(await queueService.queue.size()).toBe(0); + + const archived = await db.one("select * from queue_archive where msg_id = ?", [msg_id]); + expect(archived.queue_name).toBe("workflow_jobs"); }); - it('should return false for non-existent message', async () => { - const result = await queueService.acknowledge(999999999); - expect(result).to.be.false; + it("reports false for an unknown message", async () => { + expect(await queueService.archiveJob(999999)).toBe(false); }); }); - describe('getQueueMetrics', () => { - it('should return queue metrics', async () => { - const metrics = await queueService.getQueueMetrics(); + describe("requeue", () => { + it("makes a leased job immediately visible again", async () => { + const { msg_id } = await queueService.enqueue(jobFor(seed)); + await queueService.dequeue(300); + + expect(await queueService.dequeue()).toBeNull(); + await queueService.requeue(msg_id, 0); - expect(metrics).to.have.property('queue_name'); - expect(metrics).to.have.property('queue_length'); - expect(metrics.queue_name).to.equal('workflow_jobs'); - expect(metrics.queue_length).to.be.a('number'); + const job = await queueService.dequeue(); + expect(job?.msg_id).toBe(msg_id); }); }); - describe('purgeQueue', () => { - it('should purge all messages from queue', async () => { - // Enqueue some test messages - await queueService.enqueue({ - run_id: '123e4567-e89b-12d3-a456-426614174008', - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); + describe("getQueueMetrics", () => { + it("reports zero for an empty queue", async () => { + const metrics = await queueService.getQueueMetrics(); + expect(metrics.queue_length).toBe(0); + expect(metrics.oldest_msg_age_seconds).toBeNull(); + }); - const result = await queueService.purgeQueue(); - expect(result).to.be.a('number'); - expect(result).to.be.at.least(0); + it("counts only visible messages", async () => { + await queueService.enqueue(jobFor(seed)); + await queueService.enqueue(jobFor(seed)); + await queueService.enqueue(jobFor(seed), 60); // still invisible + + const metrics = await queueService.getQueueMetrics(); + expect(metrics.queue_length).toBe(2); + expect(metrics.oldest_msg_age_seconds).toBeGreaterThanOrEqual(0); }); }); - describe('archiveJob', () => { - it('should archive a job', async () => { - // Enqueue and dequeue a job - const runId = '123e4567-e89b-12d3-a456-426614174009'; - await queueService.enqueue({ - run_id: runId, - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); + describe("purgeQueue", () => { + it("removes every message and returns the count", async () => { + await queueService.enqueue(jobFor(seed)); + await queueService.enqueue(jobFor(seed)); - const job = await queueService.dequeue(); - if (job) { - const result = await queueService.archiveJob(job.msg_id); - expect(result).to.be.true; - } + expect(await queueService.purgeQueue()).toBe(2); + expect(await queueService.queue.size()).toBe(0); }); - }); - describe('error handling', () => { - it('should handle database connection errors gracefully', async () => { - const badClient = createClient('http://invalid-url', 'invalid-key'); - const badQueue = new QueueService(badClient); - - try { - await badQueue.enqueue({ - run_id: '123e4567-e89b-12d3-a456-426614174010', - workflow_id: '123e4567-e89b-12d3-a456-426614174001', - project_id: '123e4567-e89b-12d3-a456-426614174002', - }); - expect.fail('Should have thrown an error'); - } catch (error) { - expect(error).to.be.instanceOf(Error); - } + it("leaves other queues alone", async () => { + const other = new QueueService(db, "other_queue"); + await queueService.enqueue(jobFor(seed)); + await other.enqueue(jobFor(seed)); + + await queueService.purgeQueue(); + expect(await other.queue.size()).toBe(1); }); }); -}); \ No newline at end of file +}); diff --git a/src/queue/test-helpers.js b/src/queue/test-helpers.js new file mode 100644 index 0000000..9d92ef5 --- /dev/null +++ b/src/queue/test-helpers.js @@ -0,0 +1,84 @@ +/** + * Test helpers for the queue suite. + * + * Each test gets its own throwaway libSQL database with the real migrations + * applied. The previous tests pointed a Supabase client at localhost:54321 and + * only passed when a local Supabase stack happened to be running, so they were + * never exercised in CI. These need nothing but Node. + * + * The database is a temp file rather than ":memory:" because @libsql/client + * hands each connection its own empty in-memory database — schema created on + * one connection is invisible to the next, and every transaction would start + * blank. A file also keeps concurrently-running test files isolated from each + * other, which "file::memory:?cache=shared" would not. + */ + +import { readFileSync, readdirSync, mkdtempSync, rmSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { createDb } from "@meshhook/shared/lib/db.js"; +import { splitStatements } from "../../scripts/db-migrate.js"; + +const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), "../../migrations"); + +/** + * Create a fresh database with the full schema applied. + * @returns {Promise} A db handle; close() also deletes the temp file. + */ +export async function createTestDb() { + const dir = mkdtempSync(join(tmpdir(), "meshhook-test-")); + const db = createDb({ url: `file:${join(dir, `${randomUUID()}.db`)}` }); + + const files = readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort(); + + for (const file of files) { + const sql = readFileSync(join(migrationsDir, file), "utf8"); + for (const statement of splitStatements(sql)) { + await db.none(statement); + } + } + + const close = db.close; + db.close = async () => { + await close(); + rmSync(dir, { recursive: true, force: true }); + }; + + return db; +} + +/** + * Insert a project, workflow definition and run, returning their ids. + * + * job_tracking has a foreign key onto workflow_runs, so a queue test that + * enqueues anything needs a real run to point at. + */ +export async function seedRun(db, { status = "running" } = {}) { + const project = await db.one( + `insert into projects (owner, name) values (?, ?) returning id`, + ["11111111-1111-4111-8111-111111111111", "test-project"], + ); + + const workflow = await db.one( + `insert into workflow_definitions (project_id, slug, name, definition) + values (?, ?, ?, ?) returning id`, + [project.id, "test-workflow", "Test Workflow", JSON.stringify({ nodes: [] })], + ); + + const run = await db.one( + `insert into workflow_runs (project_id, workflow_id, status) + values (?, ?, ?) returning id`, + [project.id, workflow.id, status], + ); + + return { projectId: project.id, workflowId: workflow.id, runId: run.id }; +} + +/** A valid job payload for the seeded run. */ +export function jobFor({ runId, workflowId, projectId }, overrides = {}) { + return { run_id: runId, workflow_id: workflowId, project_id: projectId, ...overrides }; +} diff --git a/src/queue/worker.js b/src/queue/worker.js index 3e4f81a..69e7f8b 100644 --- a/src/queue/worker.js +++ b/src/queue/worker.js @@ -1,6 +1,7 @@ // Worker - Queue Job Processor -// Processes workflow jobs from PGMQ queue with retry and DLQ support +// Processes workflow jobs from the Turso/libSQL queue with retry and DLQ support +import { db as sharedDb } from '@meshhook/shared/lib/db.js'; import { QueueService } from './queue-service.js'; import { DLQService } from './dlq-service.js'; import { RetryStrategy } from './retry-strategy.js'; @@ -13,7 +14,7 @@ export class Worker { /** * Create a Worker instance * @param {Object} config - Worker configuration - * @param {Object} config.supabaseClient - Supabase client instance + * @param {Object} [config.db] - Database handle (defaults to the shared connection) * @param {Function} config.jobHandler - Function to process jobs * @param {string} config.queueName - Queue name (default: 'workflow_jobs') * @param {number} config.pollIntervalMs - Polling interval in ms (default: 1000) @@ -21,22 +22,19 @@ export class Worker { * @param {Object} config.retryConfig - Retry configuration */ constructor(config) { - if (!config.supabaseClient) { - throw new Error('Supabase client is required'); - } if (!config.jobHandler || typeof config.jobHandler !== 'function') { throw new Error('Job handler function is required'); } - this.client = config.supabaseClient; + this.db = config.db || sharedDb; this.jobHandler = config.jobHandler; this.queueName = config.queueName || 'workflow_jobs'; this.pollIntervalMs = config.pollIntervalMs || 1000; this.visibilityTimeoutSeconds = config.visibilityTimeoutSeconds || 30; // Initialize services - this.queueService = new QueueService(this.client, this.queueName); - this.dlqService = new DLQService(this.client); + this.queueService = new QueueService(this.db, this.queueName); + this.dlqService = new DLQService(this.db); this.retryStrategy = new RetryStrategy(config.retryConfig); // Worker state diff --git a/supabase/.gitignore b/supabase/.gitignore deleted file mode 100644 index ad9264f..0000000 --- a/supabase/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Supabase -.branches -.temp - -# dotenvx -.env.keys -.env.local -.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml deleted file mode 100644 index 6eab251..0000000 --- a/supabase/config.toml +++ /dev/null @@ -1,347 +0,0 @@ -# For detailed configuration reference documentation, visit: -# https://supabase.com/docs/guides/local-development/cli/config -# A string used to distinguish different Supabase projects on the same host. Defaults to the -# working directory name when running `supabase init`. -project_id = "meshhook" - -[api] -enabled = true -# Port to use for the API URL. -port = 54321 -# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API -# endpoints. `public` and `graphql_public` schemas are included by default. -schemas = ["public", "graphql_public"] -# Extra schemas to add to the search_path of every request. -extra_search_path = ["public", "extensions"] -# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size -# for accidental or malicious requests. -max_rows = 1000 - -[api.tls] -# Enable HTTPS endpoints locally using a self-signed certificate. -enabled = false -# Paths to self-signed certificate pair. -# cert_path = "../certs/my-cert.pem" -# key_path = "../certs/my-key.pem" - -[db] -# Port to use for the local database URL. -port = 54322 -# Port used by db diff command to initialize the shadow database. -shadow_port = 54320 -# The database major version to use. This has to be the same as your remote database's. Run `SHOW -# server_version;` on the remote database to check. -major_version = 17 - -[db.pooler] -enabled = false -# Port to use for the local connection pooler. -port = 54329 -# Specifies when a server connection can be reused by other clients. -# Configure one of the supported pooler modes: `transaction`, `session`. -pool_mode = "transaction" -# How many server connections to allow per user/database pair. -default_pool_size = 20 -# Maximum number of client connections allowed. -max_client_conn = 100 - -# [db.vault] -# secret_key = "env(SECRET_VALUE)" - -[db.migrations] -# If disabled, migrations will be skipped during a db push or reset. -enabled = true -# Specifies an ordered list of schema files that describe your database. -# Supports glob patterns relative to supabase directory: "./schemas/*.sql" -schema_paths = [] - -[db.seed] -# If enabled, seeds the database after migrations during a db reset. -enabled = true -# Specifies an ordered list of seed files to load during db reset. -# Supports glob patterns relative to supabase directory: "./seeds/*.sql" -sql_paths = ["./seed.sql"] - -[db.network_restrictions] -# Enable management of network restrictions. -enabled = false -# List of IPv4 CIDR blocks allowed to connect to the database. -# Defaults to allow all IPv4 connections. Set empty array to block all IPs. -allowed_cidrs = ["0.0.0.0/0"] -# List of IPv6 CIDR blocks allowed to connect to the database. -# Defaults to allow all IPv6 connections. Set empty array to block all IPs. -allowed_cidrs_v6 = ["::/0"] - -[realtime] -enabled = true -# Bind realtime via either IPv4 or IPv6. (default: IPv4) -# ip_version = "IPv6" -# The maximum length in bytes of HTTP request headers. (default: 4096) -# max_header_length = 4096 - -[studio] -enabled = true -# Port to use for Supabase Studio. -port = 54323 -# External URL of the API server that frontend connects to. -api_url = "http://127.0.0.1" -# OpenAI API Key to use for Supabase AI in the Supabase Studio. -openai_api_key = "env(OPENAI_API_KEY)" - -# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they -# are monitored, and you can view the emails that would have been sent from the web interface. -[inbucket] -enabled = true -# Port to use for the email testing server web interface. -port = 54324 -# Uncomment to expose additional ports for testing user applications that send emails. -# smtp_port = 54325 -# pop3_port = 54326 -# admin_email = "admin@email.com" -# sender_name = "Admin" - -[storage] -enabled = true -# The maximum file size allowed (e.g. "5MB", "500KB"). -file_size_limit = "50MiB" - -# Image transformation API is available to Supabase Pro plan. -# [storage.image_transformation] -# enabled = true - -# Uncomment to configure local storage buckets -# [storage.buckets.images] -# public = false -# file_size_limit = "50MiB" -# allowed_mime_types = ["image/png", "image/jpeg"] -# objects_path = "./images" - -[auth] -enabled = true -# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used -# in emails. -site_url = "http://127.0.0.1:3000" -# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://127.0.0.1:3000"] -# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). -jwt_expiry = 3600 -# Path to JWT signing key. DO NOT commit your signing keys file to git. -# signing_keys_path = "./signing_keys.json" -# If disabled, the refresh token will never expire. -enable_refresh_token_rotation = true -# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. -# Requires enable_refresh_token_rotation = true. -refresh_token_reuse_interval = 10 -# Allow/disallow new user signups to your project. -enable_signup = true -# Allow/disallow anonymous sign-ins to your project. -enable_anonymous_sign_ins = false -# Allow/disallow testing manual linking of accounts -enable_manual_linking = false -# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. -minimum_password_length = 6 -# Passwords that do not meet the following requirements will be rejected as weak. Supported values -# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` -password_requirements = "" - -[auth.rate_limit] -# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. -email_sent = 2 -# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. -sms_sent = 30 -# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. -anonymous_users = 30 -# Number of sessions that can be refreshed in a 5 minute interval per IP address. -token_refresh = 150 -# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). -sign_in_sign_ups = 30 -# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. -token_verifications = 30 -# Number of Web3 logins that can be made in a 5 minute interval per IP address. -web3 = 30 - -# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. -# [auth.captcha] -# enabled = true -# provider = "hcaptcha" -# secret = "" - -[auth.email] -# Allow/disallow new user signups via email to your project. -enable_signup = true -# If enabled, a user will be required to confirm any email change on both the old, and new email -# addresses. If disabled, only the new email is required to confirm. -double_confirm_changes = true -# If enabled, users need to confirm their email address before signing in. -enable_confirmations = false -# If enabled, users will need to reauthenticate or have logged in recently to change their password. -secure_password_change = false -# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. -max_frequency = "1s" -# Number of characters used in the email OTP. -otp_length = 6 -# Number of seconds before the email OTP expires (defaults to 1 hour). -otp_expiry = 3600 - -# Use a production-ready SMTP server -# [auth.email.smtp] -# enabled = true -# host = "smtp.sendgrid.net" -# port = 587 -# user = "apikey" -# pass = "env(SENDGRID_API_KEY)" -# admin_email = "admin@email.com" -# sender_name = "Admin" - -# Uncomment to customize email template -# [auth.email.template.invite] -# subject = "You have been invited" -# content_path = "./supabase/templates/invite.html" - -[auth.sms] -# Allow/disallow new user signups via SMS to your project. -enable_signup = false -# If enabled, users need to confirm their phone number before signing in. -enable_confirmations = false -# Template for sending OTP to users -template = "Your code is {{ .Code }}" -# Controls the minimum amount of time that must pass before sending another sms otp. -max_frequency = "5s" - -# Use pre-defined map of phone number to OTP for testing. -# [auth.sms.test_otp] -# 4152127777 = "123456" - -# Configure logged in session timeouts. -# [auth.sessions] -# Force log out after the specified duration. -# timebox = "24h" -# Force log out if the user has been inactive longer than the specified duration. -# inactivity_timeout = "8h" - -# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. -# [auth.hook.before_user_created] -# enabled = true -# uri = "pg-functions://postgres/auth/before-user-created-hook" - -# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. -# [auth.hook.custom_access_token] -# enabled = true -# uri = "pg-functions:////" - -# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. -[auth.sms.twilio] -enabled = false -account_sid = "" -message_service_sid = "" -# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: -auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" - -# Multi-factor-authentication is available to Supabase Pro plan. -[auth.mfa] -# Control how many MFA factors can be enrolled at once per user. -max_enrolled_factors = 10 - -# Control MFA via App Authenticator (TOTP) -[auth.mfa.totp] -enroll_enabled = false -verify_enabled = false - -# Configure MFA via Phone Messaging -[auth.mfa.phone] -enroll_enabled = false -verify_enabled = false -otp_length = 6 -template = "Your code is {{ .Code }}" -max_frequency = "5s" - -# Configure MFA via WebAuthn -# [auth.mfa.web_authn] -# enroll_enabled = true -# verify_enabled = true - -# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, -# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, -# `twitter`, `slack`, `spotify`, `workos`, `zoom`. -[auth.external.apple] -enabled = false -client_id = "" -# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: -secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" -# Overrides the default auth redirectUrl. -redirect_uri = "" -# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, -# or any other third-party OIDC providers. -url = "" -# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. -skip_nonce_check = false - -# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. -# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. -[auth.web3.solana] -enabled = false - -# Use Firebase Auth as a third-party provider alongside Supabase Auth. -[auth.third_party.firebase] -enabled = false -# project_id = "my-firebase-project" - -# Use Auth0 as a third-party provider alongside Supabase Auth. -[auth.third_party.auth0] -enabled = false -# tenant = "my-auth0-tenant" -# tenant_region = "us" - -# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. -[auth.third_party.aws_cognito] -enabled = false -# user_pool_id = "my-user-pool-id" -# user_pool_region = "us-east-1" - -# Use Clerk as a third-party provider alongside Supabase Auth. -[auth.third_party.clerk] -enabled = false -# Obtain from https://clerk.com/setup/supabase -# domain = "example.clerk.accounts.dev" - -# OAuth server configuration -[auth.oauth_server] -# Enable OAuth server functionality -enabled = false -# Path for OAuth consent flow UI -authorization_url_path = "/oauth/consent" -# Allow dynamic client registration -allow_dynamic_registration = false - -[edge_runtime] -enabled = true -# Supported request policies: `oneshot`, `per_worker`. -# `per_worker` (default) — enables hot reload during local development. -# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). -policy = "per_worker" -# Port to attach the Chrome inspector for debugging edge functions. -inspector_port = 8083 -# The Deno major version to use. -deno_version = 2 - -# [edge_runtime.secrets] -# secret_key = "env(SECRET_VALUE)" - -[analytics] -enabled = true -port = 54327 -# Configure one of the supported backends: `postgres`, `bigquery`. -backend = "postgres" - -# Experimental features may be deprecated any time -[experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "" -# Configures S3 bucket URL, eg. .s3-.amazonaws.com -s3_host = "env(S3_HOST)" -# Configures S3 bucket region, eg. us-east-1 -s3_region = "env(S3_REGION)" -# Configures AWS_ACCESS_KEY_ID for S3 bucket -s3_access_key = "env(S3_ACCESS_KEY)" -# Configures AWS_SECRET_ACCESS_KEY for S3 bucket -s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/migrations/20250110000001_create_core_tables.sql b/supabase/migrations/20250110000001_create_core_tables.sql deleted file mode 100644 index caeac2c..0000000 --- a/supabase/migrations/20250110000001_create_core_tables.sql +++ /dev/null @@ -1,169 +0,0 @@ --- MeshHook Core Tables Migration --- Issue #78: Create core tables migration --- This migration creates the foundational tables for the MeshHook workflow engine - --- Enable required extensions -create extension if not exists pgcrypto; - --- ============================================================================ --- PROJECTS TABLE --- Multi-tenant project isolation --- ============================================================================ -create table if not exists projects ( - id uuid primary key default gen_random_uuid(), - owner uuid not null, - name text not null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - --- Add index for owner lookups -create index if not exists idx_projects_owner on projects(owner); - --- Add trigger to update updated_at timestamp -create or replace function update_updated_at_column() -returns trigger as $$ -begin - new.updated_at = now(); - return new; -end; -$$ language plpgsql; - -create trigger update_projects_updated_at - before update on projects - for each row - execute function update_updated_at_column(); - --- ============================================================================ --- SECRETS TABLE --- Encrypted secrets vault for workflow credentials --- ============================================================================ -create table if not exists secrets ( - id uuid primary key default gen_random_uuid(), - project_id uuid not null references projects(id) on delete cascade, - key text not null, - value_encrypted bytea not null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - unique(project_id, key) -); - --- Add index for project_id lookups -create index if not exists idx_secrets_project_id on secrets(project_id); - --- Add trigger to update updated_at timestamp -create trigger update_secrets_updated_at - before update on secrets - for each row - execute function update_updated_at_column(); - --- ============================================================================ --- WORKFLOW_DEFINITIONS TABLE --- Workflow definitions with versioning support --- ============================================================================ -create table if not exists workflow_definitions ( - id uuid primary key default gen_random_uuid(), - project_id uuid not null references projects(id) on delete cascade, - slug text not null, - version int not null default 1, - definition jsonb not null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - unique(project_id, slug, version) -); - --- Add indices for common queries -create index if not exists idx_workflow_definitions_project_id on workflow_definitions(project_id); -create index if not exists idx_workflow_definitions_slug on workflow_definitions(project_id, slug); - --- Add trigger to update updated_at timestamp -create trigger update_workflow_definitions_updated_at - before update on workflow_definitions - for each row - execute function update_updated_at_column(); - --- ============================================================================ --- WORKFLOW_RUNS TABLE --- Workflow execution instances with status tracking --- ============================================================================ -create table if not exists workflow_runs ( - id uuid primary key default gen_random_uuid(), - project_id uuid not null references projects(id) on delete cascade, - workflow_id uuid not null references workflow_definitions(id) on delete cascade, - status text not null check (status in ('running', 'succeeded', 'failed', 'paused', 'canceled')), - started_at timestamptz not null default now(), - finished_at timestamptz, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - --- Add indices for hot paths -create index if not exists idx_workflow_runs_project_id on workflow_runs(project_id); -create index if not exists idx_workflow_runs_workflow_id on workflow_runs(workflow_id); -create index if not exists idx_workflow_runs_status on workflow_runs(status); -create index if not exists idx_workflow_runs_project_started on workflow_runs(project_id, started_at desc); - --- Add trigger to update updated_at timestamp -create trigger update_workflow_runs_updated_at - before update on workflow_runs - for each row - execute function update_updated_at_column(); - --- ============================================================================ --- WORKFLOW_EVENTS TABLE --- Event sourcing log for deterministic replay --- Note: This table will be partitioned in a separate migration (#88) --- ============================================================================ -create table if not exists workflow_events ( - id bigserial primary key, - run_id uuid not null references workflow_runs(id) on delete cascade, - ts timestamptz not null default now(), - type text not null, - payload jsonb not null, - created_at timestamptz not null default now() -); - --- Add indices for event queries -create index if not exists idx_workflow_events_run_id on workflow_events(run_id); -create index if not exists idx_workflow_events_run_ts on workflow_events(run_id, ts); -create index if not exists idx_workflow_events_type on workflow_events(type); - --- ============================================================================ --- AUDIT_LOG TABLE --- Admin actions and secret access tracking --- ============================================================================ -create table if not exists audit_log ( - id bigserial primary key, - project_id uuid references projects(id) on delete cascade, - user_id uuid not null, - action text not null, - resource_type text not null, - resource_id uuid, - metadata jsonb, - ip_address inet, - user_agent text, - created_at timestamptz not null default now() -); - --- Add indices for audit queries -create index if not exists idx_audit_log_project_id on audit_log(project_id); -create index if not exists idx_audit_log_user_id on audit_log(user_id); -create index if not exists idx_audit_log_created_at on audit_log(created_at desc); -create index if not exists idx_audit_log_action on audit_log(action); - --- ============================================================================ --- COMMENTS --- ============================================================================ - -comment on table projects is 'Multi-tenant project isolation - each project is a separate workspace'; -comment on table secrets is 'Encrypted secrets vault for storing workflow credentials and API keys'; -comment on table workflow_definitions is 'Workflow definitions with versioning - immutable once published'; -comment on table workflow_runs is 'Workflow execution instances with status tracking'; -comment on table workflow_events is 'Event sourcing log for deterministic replay of workflow runs'; -comment on table audit_log is 'Audit log for admin actions and secret access tracking'; - -comment on column projects.owner is 'User ID of the project owner (references auth.users)'; -comment on column secrets.value_encrypted is 'AES-GCM encrypted secret value'; -comment on column workflow_definitions.definition is 'JSONB workflow definition (DAG structure)'; -comment on column workflow_runs.status is 'Current status: running, succeeded, failed, paused, or canceled'; -comment on column workflow_events.payload is 'Event payload containing state changes and execution data'; \ No newline at end of file diff --git a/supabase/migrations/20250110000002_enable_rls_policies.sql b/supabase/migrations/20250110000002_enable_rls_policies.sql deleted file mode 100644 index f761fae..0000000 --- a/supabase/migrations/20250110000002_enable_rls_policies.sql +++ /dev/null @@ -1,261 +0,0 @@ --- MeshHook RLS Policies Migration --- Issue #86: Implement Row Level Security (RLS) policies --- This migration enables RLS and creates policies for multi-tenant data isolation - --- ============================================================================ --- ENABLE ROW LEVEL SECURITY --- ============================================================================ - --- Enable RLS on all tables -alter table projects enable row level security; -alter table secrets enable row level security; -alter table workflow_definitions enable row level security; -alter table workflow_runs enable row level security; -alter table workflow_events enable row level security; -alter table audit_log enable row level security; - --- ============================================================================ --- HELPER FUNCTION: Get user's accessible project IDs --- ============================================================================ - --- This function returns all project IDs that the current user has access to --- Currently returns projects owned by the user, but can be extended for team access -create or replace function user_project_ids() -returns setof uuid -language sql -security definer -stable -as $$ - select id from projects where owner = auth.uid(); -$$; - --- ============================================================================ --- PROJECTS TABLE POLICIES --- ============================================================================ - --- Users can view their own projects -create policy "Users can view own projects" - on projects - for select - using (owner = auth.uid()); - --- Users can insert their own projects -create policy "Users can create own projects" - on projects - for insert - with check (owner = auth.uid()); - --- Users can update their own projects -create policy "Users can update own projects" - on projects - for update - using (owner = auth.uid()) - with check (owner = auth.uid()); - --- Users can delete their own projects -create policy "Users can delete own projects" - on projects - for delete - using (owner = auth.uid()); - --- ============================================================================ --- SECRETS TABLE POLICIES --- ============================================================================ - --- Users can view secrets in their projects -create policy "Users can view secrets in own projects" - on secrets - for select - using (project_id in (select user_project_ids())); - --- Users can insert secrets in their projects -create policy "Users can create secrets in own projects" - on secrets - for insert - with check (project_id in (select user_project_ids())); - --- Users can update secrets in their projects -create policy "Users can update secrets in own projects" - on secrets - for update - using (project_id in (select user_project_ids())) - with check (project_id in (select user_project_ids())); - --- Users can delete secrets in their projects -create policy "Users can delete secrets in own projects" - on secrets - for delete - using (project_id in (select user_project_ids())); - --- ============================================================================ --- WORKFLOW_DEFINITIONS TABLE POLICIES --- ============================================================================ - --- Users can view workflow definitions in their projects -create policy "Users can view workflow definitions in own projects" - on workflow_definitions - for select - using (project_id in (select user_project_ids())); - --- Users can insert workflow definitions in their projects -create policy "Users can create workflow definitions in own projects" - on workflow_definitions - for insert - with check (project_id in (select user_project_ids())); - --- Users can update workflow definitions in their projects -create policy "Users can update workflow definitions in own projects" - on workflow_definitions - for update - using (project_id in (select user_project_ids())) - with check (project_id in (select user_project_ids())); - --- Users can delete workflow definitions in their projects -create policy "Users can delete workflow definitions in own projects" - on workflow_definitions - for delete - using (project_id in (select user_project_ids())); - --- ============================================================================ --- WORKFLOW_RUNS TABLE POLICIES --- ============================================================================ - --- Users can view workflow runs in their projects -create policy "Users can view workflow runs in own projects" - on workflow_runs - for select - using (project_id in (select user_project_ids())); - --- Users can insert workflow runs in their projects -create policy "Users can create workflow runs in own projects" - on workflow_runs - for insert - with check (project_id in (select user_project_ids())); - --- Users can update workflow runs in their projects -create policy "Users can update workflow runs in own projects" - on workflow_runs - for update - using (project_id in (select user_project_ids())) - with check (project_id in (select user_project_ids())); - --- Users can delete workflow runs in their projects -create policy "Users can delete workflow runs in own projects" - on workflow_runs - for delete - using (project_id in (select user_project_ids())); - --- ============================================================================ --- WORKFLOW_EVENTS TABLE POLICIES --- ============================================================================ - --- Users can view workflow events for runs in their projects --- Note: workflow_events doesn't have project_id, so we join through workflow_runs -create policy "Users can view workflow events in own projects" - on workflow_events - for select - using ( - run_id in ( - select id from workflow_runs - where project_id in (select user_project_ids()) - ) - ); - --- Users can insert workflow events for runs in their projects -create policy "Users can create workflow events in own projects" - on workflow_events - for insert - with check ( - run_id in ( - select id from workflow_runs - where project_id in (select user_project_ids()) - ) - ); - --- Users can update workflow events for runs in their projects -create policy "Users can update workflow events in own projects" - on workflow_events - for update - using ( - run_id in ( - select id from workflow_runs - where project_id in (select user_project_ids()) - ) - ) - with check ( - run_id in ( - select id from workflow_runs - where project_id in (select user_project_ids()) - ) - ); - --- Users can delete workflow events for runs in their projects -create policy "Users can delete workflow events in own projects" - on workflow_events - for delete - using ( - run_id in ( - select id from workflow_runs - where project_id in (select user_project_ids()) - ) - ); - --- ============================================================================ --- AUDIT_LOG TABLE POLICIES --- ============================================================================ - --- Users can view audit logs for their projects --- Note: audit_log.project_id can be null for system-level actions -create policy "Users can view audit logs in own projects" - on audit_log - for select - using ( - project_id in (select user_project_ids()) - or user_id = auth.uid() - ); - --- Users can insert audit logs for their projects -create policy "Users can create audit logs in own projects" - on audit_log - for insert - with check ( - project_id in (select user_project_ids()) - or user_id = auth.uid() - ); - --- Note: Audit logs should generally not be updated or deleted --- If needed, add policies with appropriate restrictions - --- ============================================================================ --- COMMENTS --- ============================================================================ - -comment on function user_project_ids() is 'Returns all project IDs accessible by the current authenticated user'; - -comment on policy "Users can view own projects" on projects is 'Allow users to view projects they own'; -comment on policy "Users can create own projects" on projects is 'Allow users to create projects with themselves as owner'; -comment on policy "Users can update own projects" on projects is 'Allow users to update projects they own'; -comment on policy "Users can delete own projects" on projects is 'Allow users to delete projects they own'; - -comment on policy "Users can view secrets in own projects" on secrets is 'Allow users to view secrets in their projects'; -comment on policy "Users can create secrets in own projects" on secrets is 'Allow users to create secrets in their projects'; -comment on policy "Users can update secrets in own projects" on secrets is 'Allow users to update secrets in their projects'; -comment on policy "Users can delete secrets in own projects" on secrets is 'Allow users to delete secrets in their projects'; - -comment on policy "Users can view workflow definitions in own projects" on workflow_definitions is 'Allow users to view workflow definitions in their projects'; -comment on policy "Users can create workflow definitions in own projects" on workflow_definitions is 'Allow users to create workflow definitions in their projects'; -comment on policy "Users can update workflow definitions in own projects" on workflow_definitions is 'Allow users to update workflow definitions in their projects'; -comment on policy "Users can delete workflow definitions in own projects" on workflow_definitions is 'Allow users to delete workflow definitions in their projects'; - -comment on policy "Users can view workflow runs in own projects" on workflow_runs is 'Allow users to view workflow runs in their projects'; -comment on policy "Users can create workflow runs in own projects" on workflow_runs is 'Allow users to create workflow runs in their projects'; -comment on policy "Users can update workflow runs in own projects" on workflow_runs is 'Allow users to update workflow runs in their projects'; -comment on policy "Users can delete workflow runs in own projects" on workflow_runs is 'Allow users to delete workflow runs in their projects'; - -comment on policy "Users can view workflow events in own projects" on workflow_events is 'Allow users to view workflow events for runs in their projects'; -comment on policy "Users can create workflow events in own projects" on workflow_events is 'Allow users to create workflow events for runs in their projects'; -comment on policy "Users can update workflow events in own projects" on workflow_events is 'Allow users to update workflow events for runs in their projects'; -comment on policy "Users can delete workflow events in own projects" on workflow_events is 'Allow users to delete workflow events for runs in their projects'; - -comment on policy "Users can view audit logs in own projects" on audit_log is 'Allow users to view audit logs for their projects or their own actions'; -comment on policy "Users can create audit logs in own projects" on audit_log is 'Allow users to create audit logs for their projects or their own actions'; \ No newline at end of file diff --git a/supabase/migrations/20250110000003_setup_event_partitioning.sql b/supabase/migrations/20250110000003_setup_event_partitioning.sql deleted file mode 100644 index 981bf8e..0000000 --- a/supabase/migrations/20250110000003_setup_event_partitioning.sql +++ /dev/null @@ -1,350 +0,0 @@ --- MeshHook Event Partitioning Migration --- Issue #88: Set up event partitioning --- This migration converts workflow_events to a partitioned table for better performance and maintenance - --- ============================================================================ --- STEP 1: Create new partitioned table --- ============================================================================ - --- Create the new partitioned table structure --- Note: We cannot convert an existing table to partitioned, so we create a new one -create table if not exists workflow_events_partitioned ( - id bigserial, - run_id uuid not null, - ts timestamptz not null default now(), - type text not null, - payload jsonb not null, - created_at timestamptz not null default now(), - primary key (id, ts) -) partition by range (ts); - --- Add foreign key constraint (will be inherited by partitions) -alter table workflow_events_partitioned - add constraint fk_workflow_events_run_id - foreign key (run_id) references workflow_runs(id) on delete cascade; - --- Add comment -comment on table workflow_events_partitioned is 'Event sourcing log for deterministic replay - partitioned by timestamp for performance'; - --- ============================================================================ --- STEP 2: Create initial partitions --- ============================================================================ - --- Function to create a partition for a given month -create or replace function create_workflow_events_partition( - partition_date date -) returns text as $$ -declare - partition_name text; - start_date date; - end_date date; -begin - -- Calculate partition boundaries (first day of month to first day of next month) - start_date := date_trunc('month', partition_date)::date; - end_date := (date_trunc('month', partition_date) + interval '1 month')::date; - - -- Generate partition name (e.g., workflow_events_y2025m01) - partition_name := 'workflow_events_y' || to_char(start_date, 'YYYY') || 'm' || to_char(start_date, 'MM'); - - -- Create partition if it doesn't exist - execute format( - 'create table if not exists %I partition of workflow_events_partitioned - for values from (%L) to (%L)', - partition_name, - start_date, - end_date - ); - - -- Create indices on the partition - execute format('create index if not exists %I on %I(run_id)', - partition_name || '_run_id_idx', partition_name); - execute format('create index if not exists %I on %I(run_id, ts)', - partition_name || '_run_ts_idx', partition_name); - execute format('create index if not exists %I on %I(type)', - partition_name || '_type_idx', partition_name); - - return partition_name; -end; -$$ language plpgsql; - --- Create partitions for past 2 months, current month, and next 3 months -do $$ -declare - month_offset int; - partition_name text; -begin - -- Create partitions from 2 months ago to 3 months in the future - for month_offset in -2..3 loop - partition_name := create_workflow_events_partition( - (current_date + (month_offset || ' months')::interval)::date - ); - raise notice 'Created partition: %', partition_name; - end loop; -end $$; - --- ============================================================================ --- STEP 3: Migrate existing data (if any) --- ============================================================================ - --- Copy data from old table to new partitioned table --- This will only run if the old table exists and has data -do $$ -begin - if exists ( - select 1 from information_schema.tables - where table_name = 'workflow_events' - and table_schema = current_schema() - ) then - -- Check if old table has data - if exists (select 1 from workflow_events limit 1) then - raise notice 'Migrating data from workflow_events to workflow_events_partitioned...'; - - insert into workflow_events_partitioned (id, run_id, ts, type, payload, created_at) - select id, run_id, ts, type, payload, created_at - from workflow_events - on conflict do nothing; - - raise notice 'Data migration completed'; - else - raise notice 'No data to migrate from workflow_events'; - end if; - else - raise notice 'Original workflow_events table does not exist, skipping migration'; - end if; -end $$; - --- ============================================================================ --- STEP 4: Replace old table with partitioned table --- ============================================================================ - --- Drop old table and rename new one -do $$ -begin - if exists ( - select 1 from information_schema.tables - where table_name = 'workflow_events' - and table_schema = current_schema() - ) then - drop table workflow_events cascade; - raise notice 'Dropped old workflow_events table'; - end if; -end $$; - --- Rename partitioned table to workflow_events -alter table workflow_events_partitioned rename to workflow_events; - --- Rename the foreign key constraint to match original naming -alter table workflow_events - rename constraint fk_workflow_events_run_id - to workflow_events_run_id_fkey; - --- ============================================================================ --- STEP 5: Create automatic partition management function --- ============================================================================ - --- Function to ensure future partitions exist -create or replace function maintain_workflow_events_partitions() -returns void as $$ -declare - month_offset int; - partition_name text; - partition_count int; -begin - -- Count existing future partitions - select count(*) into partition_count - from pg_tables - where schemaname = current_schema() - and tablename like 'workflow_events_y%' - and tablename >= 'workflow_events_y' || to_char(current_date, 'YYYY') || 'm' || to_char(current_date, 'MM'); - - -- Ensure we have at least 3 months of future partitions - if partition_count < 3 then - raise notice 'Creating future partitions for workflow_events...'; - - -- Create partitions for next 3 months if they don't exist - for month_offset in 0..3 loop - partition_name := create_workflow_events_partition( - (current_date + (month_offset || ' months')::interval)::date - ); - raise notice 'Ensured partition exists: %', partition_name; - end loop; - end if; -end; -$$ language plpgsql; - --- ============================================================================ --- STEP 6: Create partition cleanup function (optional) --- ============================================================================ - --- Function to drop old partitions (for data retention policies) -create or replace function drop_old_workflow_events_partitions( - retention_months int default 12 -) -returns void as $$ -declare - partition_record record; - cutoff_date date; -begin - cutoff_date := (current_date - (retention_months || ' months')::interval)::date; - - raise notice 'Dropping partitions older than %', cutoff_date; - - for partition_record in - select tablename - from pg_tables - where schemaname = current_schema() - and tablename like 'workflow_events_y%' - and tablename < 'workflow_events_y' || to_char(cutoff_date, 'YYYY') || 'm' || to_char(cutoff_date, 'MM') - loop - execute format('drop table if exists %I', partition_record.tablename); - raise notice 'Dropped old partition: %', partition_record.tablename; - end loop; -end; -$$ language plpgsql; - --- ============================================================================ --- STEP 7: Create helper views and functions --- ============================================================================ - --- View to show partition information -create or replace view workflow_events_partition_info as -select - schemaname, - tablename as partition_name, - pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size, - (select count(*) from pg_class c - where c.relname = tablename and c.relkind = 'r') as row_count_estimate -from pg_tables -where schemaname = current_schema() - and tablename like 'workflow_events_y%' -order by tablename; - -comment on view workflow_events_partition_info is 'Shows information about workflow_events partitions'; - --- Function to get partition statistics -create or replace function get_workflow_events_partition_stats() -returns table ( - partition_name text, - start_date date, - end_date date, - row_count bigint, - size_bytes bigint, - size_pretty text -) as $$ -begin - return query - select - c.relname::text as partition_name, - pg_get_expr(c.relpartbound, c.oid)::text as bounds, - null::date as start_date, - null::date as end_date, - c.reltuples::bigint as row_count, - pg_total_relation_size(c.oid) as size_bytes, - pg_size_pretty(pg_total_relation_size(c.oid)) as size_pretty - from pg_class c - join pg_inherits i on i.inhrelid = c.oid - join pg_class p on p.oid = i.inhparent - where p.relname = 'workflow_events' - and c.relkind = 'r' - order by c.relname; -end; -$$ language plpgsql; - --- ============================================================================ --- STEP 8: Set up automatic partition maintenance (using pg_cron if available) --- ============================================================================ - --- Note: This requires pg_cron extension. If not available, run maintain_workflow_events_partitions() manually --- or set up an external cron job - --- Check if pg_cron is available and create scheduled job -do $$ -begin - if exists (select 1 from pg_extension where extname = 'pg_cron') then - -- Schedule partition maintenance to run daily at 2 AM - perform cron.schedule( - 'maintain-workflow-events-partitions', - '0 2 * * *', - 'select maintain_workflow_events_partitions()' - ); - raise notice 'Scheduled automatic partition maintenance with pg_cron'; - else - raise notice 'pg_cron extension not available. Please run maintain_workflow_events_partitions() manually or via external cron'; - end if; -exception - when others then - raise notice 'Could not schedule automatic partition maintenance: %', sqlerrm; -end $$; - --- ============================================================================ --- STEP 9: Create convenience functions for common queries --- ============================================================================ - --- Function to get events for a run (optimized for partitioned table) -create or replace function get_workflow_run_events( - p_run_id uuid, - p_start_ts timestamptz default null, - p_end_ts timestamptz default null -) -returns table ( - id bigint, - run_id uuid, - ts timestamptz, - type text, - payload jsonb, - created_at timestamptz -) as $$ -begin - return query - select e.id, e.run_id, e.ts, e.type, e.payload, e.created_at - from workflow_events e - where e.run_id = p_run_id - and (p_start_ts is null or e.ts >= p_start_ts) - and (p_end_ts is null or e.ts <= p_end_ts) - order by e.ts, e.id; -end; -$$ language plpgsql stable; - -comment on function get_workflow_run_events is 'Efficiently retrieves events for a workflow run with optional time range filtering'; - --- ============================================================================ --- DOCUMENTATION --- ============================================================================ - -comment on function create_workflow_events_partition is 'Creates a monthly partition for workflow_events table'; -comment on function maintain_workflow_events_partitions is 'Ensures at least 3 months of future partitions exist'; -comment on function drop_old_workflow_events_partitions is 'Drops partitions older than specified retention period (default 12 months)'; -comment on function get_workflow_events_partition_stats is 'Returns statistics about all workflow_events partitions'; - --- ============================================================================ --- VERIFICATION --- ============================================================================ - --- Verify partitioning is set up correctly -do $$ -declare - partition_count int; - parent_table_name text; -begin - -- Check if table is partitioned - select relname into parent_table_name - from pg_class - where relname = 'workflow_events' - and relkind = 'p'; -- 'p' means partitioned table - - if parent_table_name is null then - raise exception 'workflow_events is not a partitioned table!'; - end if; - - -- Count partitions - select count(*) into partition_count - from pg_inherits i - join pg_class c on c.oid = i.inhrelid - join pg_class p on p.oid = i.inhparent - where p.relname = 'workflow_events'; - - raise notice 'Partitioning setup complete!'; - raise notice 'Parent table: %', parent_table_name; - raise notice 'Number of partitions: %', partition_count; - raise notice 'Run "select * from workflow_events_partition_info;" to see partition details'; -end $$; \ No newline at end of file diff --git a/supabase/migrations/20250110000004_setup_pgmq_queues.sql b/supabase/migrations/20250110000004_setup_pgmq_queues.sql deleted file mode 100644 index 460400b..0000000 --- a/supabase/migrations/20250110000004_setup_pgmq_queues.sql +++ /dev/null @@ -1,267 +0,0 @@ --- MeshHook PGMQ Queue Setup Migration --- Issue #90: Create queue tables/setup --- This migration installs PGMQ extension and creates queues for workflow job processing - --- ============================================================================ --- INSTALL PGMQ EXTENSION --- ============================================================================ - --- Install PGMQ extension (requires superuser or appropriate permissions) --- Note: On Supabase, this may need to be enabled via the dashboard -create extension if not exists pgmq cascade; - --- ============================================================================ --- ENSURE HELPER FUNCTION EXISTS --- ============================================================================ - --- Ensure the update_updated_at_column function exists in public schema --- (should be created in migration 1, but we ensure it here for safety) -create or replace function public.update_updated_at_column() -returns trigger as $$ -begin - new.updated_at = now(); - return new; -end; -$$ language plpgsql; - --- ============================================================================ --- CREATE MAIN WORKFLOW JOBS QUEUE --- ============================================================================ - --- Create the main queue for workflow job processing --- Queue name: workflow_jobs --- VT (visibility timeout): 30 seconds (default) -select pgmq.create('workflow_jobs'); - --- ============================================================================ --- CREATE DEAD LETTER QUEUE (DLQ) --- ============================================================================ - --- Create dead letter queue for failed jobs that exceed max retry attempts --- Queue name: workflow_jobs_dlq -select pgmq.create('workflow_jobs_dlq'); - --- ============================================================================ --- QUEUE CONFIGURATION TABLE --- ============================================================================ - --- Store queue configuration and metadata -create table if not exists public.queue_config ( - id uuid primary key default gen_random_uuid(), - queue_name text not null unique, - visibility_timeout_seconds int not null default 30, - max_retry_attempts int not null default 5, - retry_backoff_base_ms int not null default 1000, - retry_backoff_max_ms int not null default 300000, - dlq_enabled boolean not null default true, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - --- Add trigger to update updated_at timestamp -create trigger update_queue_config_updated_at - before update on public.queue_config - for each row - execute function public.update_updated_at_column(); - --- Insert default configuration for workflow_jobs queue -insert into public.queue_config ( - queue_name, - visibility_timeout_seconds, - max_retry_attempts, - retry_backoff_base_ms, - retry_backoff_max_ms, - dlq_enabled -) values ( - 'workflow_jobs', - 30, - 5, - 1000, - 300000, - true -) on conflict (queue_name) do nothing; - --- Insert configuration for DLQ -insert into public.queue_config ( - queue_name, - visibility_timeout_seconds, - max_retry_attempts, - retry_backoff_base_ms, - retry_backoff_max_ms, - dlq_enabled -) values ( - 'workflow_jobs_dlq', - 300, - 0, - 0, - 0, - false -) on conflict (queue_name) do nothing; - --- ============================================================================ --- JOB TRACKING TABLE --- ============================================================================ - --- Track job processing history and retry attempts -create table if not exists public.job_tracking ( - id uuid primary key default gen_random_uuid(), - msg_id bigint not null, - run_id uuid not null references public.workflow_runs(id) on delete cascade, - queue_name text not null, - attempt int not null default 1, - max_attempts int not null default 5, - enqueued_at timestamptz not null default now(), - started_at timestamptz, - completed_at timestamptz, - failed_at timestamptz, - moved_to_dlq_at timestamptz, - error_message text, - error_stack text, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - --- Add indices for job tracking queries -create index if not exists idx_job_tracking_msg_id on public.job_tracking(msg_id); -create index if not exists idx_job_tracking_run_id on public.job_tracking(run_id); -create index if not exists idx_job_tracking_queue_name on public.job_tracking(queue_name); -create index if not exists idx_job_tracking_enqueued_at on public.job_tracking(enqueued_at desc); -create index if not exists idx_job_tracking_status on public.job_tracking(completed_at, failed_at, moved_to_dlq_at); - --- Add trigger to update updated_at timestamp -create trigger update_job_tracking_updated_at - before update on public.job_tracking - for each row - execute function public.update_updated_at_column(); - --- ============================================================================ --- HELPER FUNCTIONS --- ============================================================================ - --- Function to get queue metrics -create or replace function public.get_queue_metrics(p_queue_name text) -returns table ( - queue_name text, - queue_length bigint, - oldest_msg_age_seconds numeric, - newest_msg_age_seconds numeric -) as $$ -begin - return query - select - p_queue_name::text, - pgmq.queue_length(p_queue_name), - extract(epoch from (now() - min(enqueued_at)))::numeric as oldest_msg_age_seconds, - extract(epoch from (now() - max(enqueued_at)))::numeric as newest_msg_age_seconds - from pgmq.q_workflow_jobs; -end; -$$ language plpgsql; - --- Function to purge old archived messages -create or replace function public.purge_old_queue_archives(p_days int default 30) -returns bigint as $$ -declare - v_deleted bigint; -begin - -- Delete archived messages older than specified days - delete from pgmq.a_workflow_jobs - where archived_at < now() - (p_days || ' days')::interval; - - get diagnostics v_deleted = row_count; - return v_deleted; -end; -$$ language plpgsql; - --- Function to move job to DLQ -create or replace function public.move_job_to_dlq( - p_msg_id bigint, - p_message jsonb, - p_error_message text default null -) -returns bigint as $$ -declare - v_dlq_msg_id bigint; - v_enhanced_message jsonb; -begin - -- Enhance message with DLQ metadata - v_enhanced_message := p_message || jsonb_build_object( - 'moved_to_dlq_at', now(), - 'original_msg_id', p_msg_id, - 'error_message', p_error_message - ); - - -- Send to DLQ - select msg_id into v_dlq_msg_id - from pgmq.send('workflow_jobs_dlq', v_enhanced_message); - - -- Archive original message - perform pgmq.archive('workflow_jobs', p_msg_id); - - return v_dlq_msg_id; -end; -$$ language plpgsql; - --- ============================================================================ --- QUEUE MONITORING VIEW --- ============================================================================ - --- Create view for queue monitoring -create or replace view public.queue_monitoring as -select - 'workflow_jobs' as queue_name, - count(*) as pending_jobs, - count(*) filter (where vt > now()) as invisible_jobs, - count(*) filter (where vt <= now()) as visible_jobs, - min(enqueued_at) as oldest_job_time, - max(enqueued_at) as newest_job_time, - extract(epoch from (now() - min(enqueued_at)))::int as oldest_job_age_seconds -from pgmq.q_workflow_jobs -union all -select - 'workflow_jobs_dlq' as queue_name, - count(*) as pending_jobs, - count(*) filter (where vt > now()) as invisible_jobs, - count(*) filter (where vt <= now()) as visible_jobs, - min(enqueued_at) as oldest_job_time, - max(enqueued_at) as newest_job_time, - extract(epoch from (now() - min(enqueued_at)))::int as oldest_job_age_seconds -from pgmq.q_workflow_jobs_dlq; - --- ============================================================================ --- JOB STATISTICS VIEW --- ============================================================================ - --- Create view for job statistics -create or replace view public.job_statistics as -select - queue_name, - count(*) as total_jobs, - count(*) filter (where completed_at is not null) as completed_jobs, - count(*) filter (where failed_at is not null) as failed_jobs, - count(*) filter (where moved_to_dlq_at is not null) as dlq_jobs, - count(*) filter (where completed_at is null and failed_at is null and moved_to_dlq_at is null) as pending_jobs, - avg(extract(epoch from (completed_at - started_at))) filter (where completed_at is not null) as avg_processing_time_seconds, - avg(attempt) as avg_attempts, - max(attempt) as max_attempts_seen -from public.job_tracking -group by queue_name; - --- ============================================================================ --- COMMENTS --- ============================================================================ - -comment on table public.queue_config is 'Configuration for PGMQ queues including retry and DLQ settings'; -comment on table public.job_tracking is 'Tracks job processing history, retry attempts, and failures'; -comment on function public.get_queue_metrics(text) is 'Returns current metrics for a specified queue'; -comment on function public.purge_old_queue_archives(int) is 'Purges archived queue messages older than specified days'; -comment on function public.move_job_to_dlq(bigint, jsonb, text) is 'Moves a failed job to the dead letter queue'; -comment on view public.queue_monitoring is 'Real-time monitoring view for queue status'; -comment on view public.job_statistics is 'Aggregated statistics for job processing'; - --- ============================================================================ --- GRANTS (if needed for specific roles) --- ============================================================================ - --- Grant access to authenticated users (adjust as needed for your security model) --- grant select on queue_monitoring to authenticated; --- grant select on job_statistics to authenticated; \ No newline at end of file diff --git a/supabase/migrations/20250110000005_create_workflows_view.sql b/supabase/migrations/20250110000005_create_workflows_view.sql deleted file mode 100644 index b5da14b..0000000 --- a/supabase/migrations/20250110000005_create_workflows_view.sql +++ /dev/null @@ -1,33 +0,0 @@ --- MeshHook Workflows View Migration --- Creates a view to map 'workflows' to 'workflow_definitions' for backward compatibility --- This resolves the error: "Could not find the table 'public.workflows' in the schema cache" - --- ============================================================================ --- CREATE WORKFLOWS VIEW --- ============================================================================ - --- Create a view that presents workflow_definitions as 'workflows' --- This provides a simpler interface for the application layer -create or replace view public.workflows as -select - id, - project_id, - slug, - version, - definition, - created_at, - updated_at -from public.workflow_definitions; - --- ============================================================================ --- COMMENTS --- ============================================================================ - -comment on view public.workflows is 'View mapping workflow_definitions to workflows for simpler application interface'; - --- ============================================================================ --- GRANTS --- ============================================================================ - --- Grant access to authenticated users -grant select on public.workflows to authenticated; \ No newline at end of file diff --git a/supabase/migrations/20250110000006_add_workflow_metadata_columns.sql b/supabase/migrations/20250110000006_add_workflow_metadata_columns.sql deleted file mode 100644 index 7ce9e9b..0000000 --- a/supabase/migrations/20250110000006_add_workflow_metadata_columns.sql +++ /dev/null @@ -1,78 +0,0 @@ --- MeshHook Workflow Metadata Columns Migration --- Adds name, description, and status columns to workflow_definitions table --- Updates the workflows view to include these columns - --- ============================================================================ --- ADD METADATA COLUMNS TO WORKFLOW_DEFINITIONS --- ============================================================================ - --- Add name column (separate from slug for display purposes) -alter table workflow_definitions -add column if not exists name text; - --- Add description column for workflow documentation -alter table workflow_definitions -add column if not exists description text; - --- Add status column for workflow lifecycle management -alter table workflow_definitions -add column if not exists status text default 'draft' check (status in ('draft', 'published', 'archived')); - --- Add user_id column for user ownership (in addition to project_id) -alter table workflow_definitions -add column if not exists user_id uuid; - --- Update existing rows to have a name based on slug if name is null -update workflow_definitions -set name = slug -where name is null; - --- Make name required going forward -alter table workflow_definitions -alter column name set not null; - --- Add index for status queries -create index if not exists idx_workflow_definitions_status on workflow_definitions(status); - --- Add index for user_id queries -create index if not exists idx_workflow_definitions_user_id on workflow_definitions(user_id); - --- ============================================================================ --- UPDATE WORKFLOWS VIEW --- ============================================================================ - --- Drop and recreate the view to include new columns -drop view if exists public.workflows; - -create or replace view public.workflows as -select - id, - project_id, - slug, - name, - description, - status, - user_id, - version, - definition, - created_at, - updated_at -from public.workflow_definitions; - --- ============================================================================ --- COMMENTS --- ============================================================================ - -comment on column workflow_definitions.name is 'Display name for the workflow (user-friendly)'; -comment on column workflow_definitions.description is 'Optional description of what the workflow does'; -comment on column workflow_definitions.status is 'Workflow lifecycle status: draft, published, or archived'; -comment on column workflow_definitions.user_id is 'User who created/owns this workflow'; - -comment on view public.workflows is 'View mapping workflow_definitions to workflows with all metadata columns'; - --- ============================================================================ --- GRANTS --- ============================================================================ - --- Grant access to authenticated users -grant select on public.workflows to authenticated; \ No newline at end of file diff --git a/supabase/migrations/20250110000007_reload_schema_cache.sql b/supabase/migrations/20250110000007_reload_schema_cache.sql deleted file mode 100644 index a46a26e..0000000 --- a/supabase/migrations/20250110000007_reload_schema_cache.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Force PostgREST schema cache reload --- This ensures the new columns in the workflows view are recognized - --- Send NOTIFY signal to reload schema cache -NOTIFY pgrst, 'reload schema'; - --- Add a comment to track this reload -COMMENT ON VIEW public.workflows IS 'View mapping workflow_definitions to workflows - schema reloaded 2025-01-10'; \ No newline at end of file diff --git a/supabase/migrations/20250111000008_add_theme_preference.sql b/supabase/migrations/20250111000008_add_theme_preference.sql deleted file mode 100644 index 0da7041..0000000 --- a/supabase/migrations/20250111000008_add_theme_preference.sql +++ /dev/null @@ -1,49 +0,0 @@ --- Add theme_preference to user_settings table --- Create user_settings table if it doesn't exist -CREATE TABLE IF NOT EXISTS user_settings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE NOT NULL, - theme_preference TEXT DEFAULT 'light' CHECK (theme_preference IN ('light', 'dark')), - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - --- Add theme_preference column if table already exists but column doesn't -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'user_settings' AND column_name = 'theme_preference' - ) THEN - ALTER TABLE user_settings - ADD COLUMN theme_preference TEXT DEFAULT 'light' - CHECK (theme_preference IN ('light', 'dark')); - END IF; -END $$; - --- Create indexes for faster queries -CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id); -CREATE INDEX IF NOT EXISTS idx_user_settings_theme ON user_settings(theme_preference); - --- Enable Row Level Security -ALTER TABLE user_settings ENABLE ROW LEVEL SECURITY; - --- Drop existing policies if they exist -DROP POLICY IF EXISTS "Users can read own settings" ON user_settings; -DROP POLICY IF EXISTS "Users can insert own settings" ON user_settings; -DROP POLICY IF EXISTS "Users can update own settings" ON user_settings; - --- Policy: Users can read their own settings -CREATE POLICY "Users can read own settings" - ON user_settings FOR SELECT - USING (auth.uid() = user_id); - --- Policy: Users can insert their own settings -CREATE POLICY "Users can insert own settings" - ON user_settings FOR INSERT - WITH CHECK (auth.uid() = user_id); - --- Policy: Users can update their own settings -CREATE POLICY "Users can update own settings" - ON user_settings FOR UPDATE - USING (auth.uid() = user_id); \ No newline at end of file diff --git a/supabase/migrations/20250112000009_fix_workflows_view_rls.sql b/supabase/migrations/20250112000009_fix_workflows_view_rls.sql deleted file mode 100644 index ef435f9..0000000 --- a/supabase/migrations/20250112000009_fix_workflows_view_rls.sql +++ /dev/null @@ -1,46 +0,0 @@ --- MeshHook Workflows View RLS Fix --- Fixes security issue where workflows view doesn't enforce RLS policies --- This ensures users can only see workflows in their own projects - --- ============================================================================ --- DROP AND RECREATE WORKFLOWS VIEW WITH SECURITY INVOKER --- ============================================================================ - --- Drop the existing view -drop view if exists public.workflows; - --- Recreate the view with security invoker to enforce RLS --- This makes the view execute with the privileges of the user calling it, --- not the user who created it, which allows RLS policies to be enforced -create or replace view public.workflows -with (security_invoker = true) -as -select - id, - project_id, - slug, - name, - description, - status, - user_id, - version, - definition, - created_at, - updated_at -from public.workflow_definitions; - --- ============================================================================ --- COMMENTS --- ============================================================================ - -comment on view public.workflows is 'View mapping workflow_definitions to workflows with RLS enforcement via security_invoker'; - --- ============================================================================ --- GRANTS --- ============================================================================ - --- Grant access to authenticated users -grant select on public.workflows to authenticated; -grant insert on public.workflows to authenticated; -grant update on public.workflows to authenticated; -grant delete on public.workflows to authenticated; \ No newline at end of file diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..ddd16a2 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // describe/it/expect as globals, so the retry-strategy suite keeps working + // with its original chai assertions. + globals: true, + environment: "node", + // Only the suites that are actually written for vitest. src/nodes, + // src/workers and src/utils use the node:test runner instead and are run by + // `pnpm run test:node`; collecting them here just reports "no test suite". + include: ["src/queue/**/*.test.js", "packages/**/*.test.js", "scripts/**/*.test.js"], + exclude: ["**/node_modules/**", "apps/**"], + // Queue tests exercise visibility timeouts and a polling worker. + testTimeout: 15000, + }, +}); diff --git a/workers/http-exec.mjs b/workers/http-exec.mjs index 708f880..d59d9ed 100644 --- a/workers/http-exec.mjs +++ b/workers/http-exec.mjs @@ -24,12 +24,12 @@ async function execHttp(runId, node) { }); const text = await res.body.text(); await db.none( - "insert into workflow_events (run_id, type, payload) values ($1,'http_attempted',$2::jsonb)", + "insert into workflow_events (run_id, type, payload) values ($1,'http_attempted',$2)", [runId, JSON.stringify({ node, status: res.statusCode })] ); if (res.statusCode >= 200 && res.statusCode < 300) { await db.none( - "insert into workflow_events (run_id, type, payload) values ($1,'step_succeeded',$2::jsonb)", + "insert into workflow_events (run_id, type, payload) values ($1,'step_succeeded',$2)", [runId, JSON.stringify({ node, next: node.id === 'createContact' ? 'terminate' : null, response: text.slice(0,2048) })] ); return; @@ -38,7 +38,7 @@ async function execHttp(runId, node) { } catch (err) { const backoff = Math.min(8000, base * 2 ** (attempt - 1)) + Math.floor(Math.random() * 250); await db.none( - "insert into workflow_events (run_id, type, payload) values ($1,'step_failed',$2::jsonb)", + "insert into workflow_events (run_id, type, payload) values ($1,'step_failed',$2)", [runId, JSON.stringify({ node, attempt, error: String(err) })] ); if (attempt >= max) throw err; @@ -53,15 +53,15 @@ async function handleStep(job) { await execHttp(runId, node); } else if (node.type === "transform") { await db.none( - "insert into workflow_events (run_id, type, payload) values ($1,'step_succeeded',$2::jsonb)", + "insert into workflow_events (run_id, type, payload) values ($1,'step_succeeded',$2)", [runId, JSON.stringify({ node, next: "createContact", output: { ok: true } })] ); } else if (node.type === "terminate") { await db.none( - "insert into workflow_events (run_id, type, payload) values ($1,'run_completed',$2::jsonb)", + "insert into workflow_events (run_id, type, payload) values ($1,'run_completed',$2)", [runId, JSON.stringify({ reason: "terminated" })] ); - await db.none("update workflow_runs set status='succeeded', finished_at=now() where id=$1", [runId]); + await db.none("update workflow_runs set status='succeeded', finished_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') where id=$1", [runId]); } } diff --git a/workers/lib/db.js b/workers/lib/db.js index 9353dc2..47f05e2 100644 --- a/workers/lib/db.js +++ b/workers/lib/db.js @@ -1,45 +1,9 @@ -import pg from "pg"; -import { config } from "dotenv"; -import { fileURLToPath } from "url"; -import { dirname, join } from "path"; - -// Load environment variables -// In production, .env is used (not committed) -// In development, .env.local is used (committed for easy setup) -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const rootDir = join(__dirname, "../.."); - -// Try to load .env first (production), then fall back to .env.local (development) -config({ path: join(rootDir, ".env") }); -config({ path: join(rootDir, ".env.local") }); - -// Validate DATABASE_URL is set -if (!process.env.DATABASE_URL) { - throw new Error( - "DATABASE_URL is not set. Please check your .env or .env.local file." - ); -} - -const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); -export const db = { - one: async (q, p=[]) => (await pool.query(q, p)).rows[0], - oneOrNone: async (q, p=[]) => (await pool.query(q, p)).rows[0] ?? null, - manyOrNone: async (q, p=[]) => (await pool.query(q, p)).rows, - none: async (q, p=[]) => { await pool.query(q, p); }, - tx: async (fn) => { - const client = await pool.connect(); - try { - await client.query("begin"); - const tdb = { - one: (q,p=[]) => client.query(q,p).then(r=>r.rows[0]), - none: (q,p=[]) => client.query(q,p).then(()=>{}), - }; - const res = await fn(tdb); - await client.query("commit"); - return res; - } catch (e) { - await client.query("rollback"); throw e; - } finally { client.release(); } - } -}; +/** + * Worker database handle. + * + * This used to be a byte-for-byte copy of packages/shared/lib/db.js, which meant + * the Postgres pool was configured twice and the two copies could drift. The + * libSQL client is re-exported from the shared package instead. + */ + +export { db, json, now, createDb } from "@meshhook/shared/lib/db.js"; diff --git a/workers/orchestrator.mjs b/workers/orchestrator.mjs index a82f832..4ab86b7 100644 --- a/workers/orchestrator.mjs +++ b/workers/orchestrator.mjs @@ -1,4 +1,4 @@ -import { db } from "./lib/db.js"; +import { db, json } from "./lib/db.js"; import { queue, enqueueStep } from "./lib/queue.js"; async function replay(runId) { @@ -8,7 +8,8 @@ async function replay(runId) { ); let ctx = { current: null }; for (const ev of events) { - if (ev.type === "step_succeeded") ctx.current = ev.payload.next ?? null; + // payload is TEXT under SQLite, not a decoded jsonb value. + if (ev.type === "step_succeeded") ctx.current = json(ev.payload, {}).next ?? null; } return ctx; } @@ -30,13 +31,13 @@ async function handleRun(job) { "insert into workflow_events (run_id, type, payload) values ($1,'run_completed','{}')", [runId] ); - await t.none("update workflow_runs set status='succeeded', finished_at=now() where id=$1", [runId]); + await t.none("update workflow_runs set status='succeeded', finished_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') where id=$1", [runId]); }); return; } for (const node of nodes) { await db.none( - "insert into workflow_events (run_id, type, payload) values ($1,'step_started',$2::jsonb)", + "insert into workflow_events (run_id, type, payload) values ($1,'step_started',$2)", [runId, JSON.stringify({ node })] ); await enqueueStep(runId, node); diff --git a/workers/package.json b/workers/package.json new file mode 100644 index 0000000..58a479b --- /dev/null +++ b/workers/package.json @@ -0,0 +1,14 @@ +{ + "name": "@meshhook/workers", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "orchestrator.mjs", + "scripts": { + "start": "node orchestrator.mjs" + }, + "dependencies": { + "@meshhook/shared": "workspace:*", + "undici": "^6.19.8" + } +}