diff --git a/.github/actions/deploy-env/action.yml b/.github/actions/deploy-env/action.yml new file mode 100644 index 000000000..8b625cdc7 --- /dev/null +++ b/.github/actions/deploy-env/action.yml @@ -0,0 +1,28 @@ +name: deploy setup +description: >- + Toolchain and dependencies for an alchemy deploy job. Secrets come from the job's GitHub + Environment (`production` or `pr-preview`) as env vars; alchemy reads them through Config + when it plans the stack (see apps/*/alchemy.run.ts and apps/backend/src/worker/env.ts). + +runs: + using: composite + steps: + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.14 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + */node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + shell: bash + run: bun install --frozen-lockfile diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 01ae3f36e..b1a8956fc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -68,6 +68,9 @@ jobs: - name: Type check run: bun run typecheck + - name: Type check the alchemy stack + run: bun run alchemy:typecheck + test: runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/deploy-pr-preview.yml b/.github/workflows/deploy-pr-preview.yml new file mode 100644 index 000000000..db44e026c --- /dev/null +++ b/.github/workflows/deploy-pr-preview.yml @@ -0,0 +1,83 @@ +name: Deploy PR Preview (Cloudflare via Alchemy) + +# Label-gated: a PR gets its own stage (`pr-`) while it carries `preview`, torn down when the +# label comes off or the PR closes. Previews share one preview database (`HAZEL_PG_URL`). +on: + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled, closed] + +concurrency: + group: pr-preview-${{ github.event.pull_request.number }} + # Queue instead of cancelling: whether to cancel is decided by the *incoming* run, so any + # cancellation could cut short an in-progress teardown and leak the preview stage. `queue: max` + # keeps every pending run too (the default replaces a pending teardown with the next event). + cancel-in-progress: false + queue: max + +permissions: + contents: read + pull-requests: write + +jobs: + preview: + if: >- + ${{ github.event.pull_request.head.repo.full_name == github.repository + && ((github.event.action == 'unlabeled' && github.event.label.name == 'preview') + || (github.event.action != 'unlabeled' + && contains(github.event.pull_request.labels.*.name, 'preview'))) }} + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: + name: pr-preview + url: ${{ steps.deploy.outputs.web_url }} + env: + # `unlabeled` only reaches here for the `preview` label, so both mean teardown. + TEARDOWN: ${{ (github.event.action == 'closed' || github.event.action == 'unlabeled') && 'true' || 'false' }} + PR_NUMBER: ${{ github.event.pull_request.number }} + COMMIT_SHA: ${{ github.event.pull_request.head.sha }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + HAZEL_PG_URL: ${{ secrets.HAZEL_PG_URL }} + VITE_CLERK_PUBLISHABLE_KEY: ${{ vars.VITE_CLERK_PUBLISHABLE_KEY }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_PUBLIC_URL: ${{ vars.S3_PUBLIC_URL }} + S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} + S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} + CLUSTER_URL: ${{ vars.CLUSTER_URL }} + CLUSTER_API_SECRET: ${{ secrets.CLUSTER_API_SECRET }} + INTEGRATION_ENCRYPTION_KEY: ${{ secrets.INTEGRATION_ENCRYPTION_KEY }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Deploy setup + uses: ./.github/actions/deploy-env + + - name: Deploy preview stage + id: deploy + if: ${{ env.TEARDOWN != 'true' }} + run: bun run alchemy:deploy:pr + + - name: Destroy preview stage + if: ${{ env.TEARDOWN == 'true' }} + run: bun run alchemy:destroy:pr + + - name: Comment the preview URLs + if: ${{ env.TEARDOWN != 'true' && steps.deploy.outcome == 'success' }} + env: + GH_TOKEN: ${{ github.token }} + WEB_URL: ${{ steps.deploy.outputs.web_url }} + API_URL: ${{ steps.deploy.outputs.api_url }} + run: | + marker="" + body="$marker + **Preview** for \`${COMMIT_SHA:0:7}\`: app $WEB_URL · api $API_URL" + existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -1) + if [ -n "$existing" ]; then + gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" -f body="$body" >/dev/null + else + gh pr comment "$PR_NUMBER" --body "$body" + fi diff --git a/.github/workflows/deploy-prd.yml b/.github/workflows/deploy-prd.yml new file mode 100644 index 000000000..34dec8611 --- /dev/null +++ b/.github/workflows/deploy-prd.yml @@ -0,0 +1,94 @@ +name: Deploy PRD (Cloudflare via Alchemy) + +# Gated on CI passing on main (not `push: main`); `workflow_dispatch` skips the gate. +on: + workflow_run: + workflows: ["Test"] + types: [completed] + branches: [main] + workflow_dispatch: + +concurrency: + group: deploy-prd + cancel-in-progress: false + +permissions: + contents: read + +jobs: + deploy: + # Only CI runs for pushes to this repo's main deploy; a PR's Test run must never reach prd. + if: >- + ${{ github.event_name == 'workflow_dispatch' + || (github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.event == 'push' + && github.event.workflow_run.head_repository.full_name == github.repository) }} + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: + name: production + url: https://app.hazel.sh + env: + # On workflow_run, `github.sha` is the branch head, not the commit CI tested. + COMMIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} + PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} + PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} + # Build inputs (web) + VITE_CLERK_PUBLISHABLE_KEY: ${{ vars.VITE_CLERK_PUBLISHABLE_KEY }} + VITE_PUBLIC_POSTHOG_KEY: ${{ vars.VITE_PUBLIC_POSTHOG_KEY }} + VITE_MAPLE_PUBLIC_KEY: ${{ vars.VITE_MAPLE_PUBLIC_KEY }} + # api Worker (apps/backend/src/worker/env.ts) + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }} + COOKIE_DOMAIN: ${{ vars.COOKIE_DOMAIN }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_PUBLIC_URL: ${{ vars.S3_PUBLIC_URL }} + S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} + S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} + CLUSTER_URL: ${{ vars.CLUSTER_URL }} + CLUSTER_API_SECRET: ${{ secrets.CLUSTER_API_SECRET }} + INTEGRATION_ENCRYPTION_KEY: ${{ secrets.INTEGRATION_ENCRYPTION_KEY }} + INTEGRATION_ENCRYPTION_KEY_VERSION: ${{ vars.INTEGRATION_ENCRYPTION_KEY_VERSION }} + INTEGRATION_ENCRYPTION_KEY_PREV: ${{ secrets.INTEGRATION_ENCRYPTION_KEY_PREV }} + INTEGRATION_ENCRYPTION_KEY_VERSION_PREV: ${{ vars.INTEGRATION_ENCRYPTION_KEY_VERSION_PREV }} + LINEAR_CLIENT_ID: ${{ vars.LINEAR_CLIENT_ID }} + LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} + DISCORD_CLIENT_ID: ${{ vars.DISCORD_CLIENT_ID }} + DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_GATEWAY_ENABLED: ${{ vars.DISCORD_GATEWAY_ENABLED }} + GITHUB_APP_ID: ${{ vars.HAZEL_GITHUB_APP_ID }} + GITHUB_APP_SLUG: ${{ vars.HAZEL_GITHUB_APP_SLUG }} + GITHUB_APP_PRIVATE_KEY: ${{ secrets.HAZEL_GITHUB_APP_PRIVATE_KEY }} + GITHUB_WEBHOOK_SECRET: ${{ secrets.HAZEL_GITHUB_WEBHOOK_SECRET }} + INTERNAL_SECRET: ${{ secrets.INTERNAL_SECRET }} + KLIPY_API_KEY: ${{ secrets.KLIPY_API_KEY }} + # electric-proxy + self-hosted Electric (DATABASE_URL comes from the stack's replication role) + ELECTRIC_SECRET: ${{ secrets.ELECTRIC_SECRET }} + OTEL_BASE_URL: ${{ vars.OTEL_BASE_URL }} + MAPLE_INGEST_KEY: ${{ secrets.MAPLE_INGEST_KEY }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ env.COMMIT_SHA }} + + - name: Deploy setup + uses: ./.github/actions/deploy-env + + - name: Deploy with Alchemy + run: bun run alchemy:deploy:prd + + - name: Check the api serves this revision + run: | + for attempt in 1 2 3 4 5 6; do + if curl -fsS https://api.hazel.sh/health >/dev/null; then exit 0; fi + sleep 10 + done + echo "::error::api.hazel.sh/health did not answer after the deploy" + exit 1 diff --git a/.gitignore b/.gitignore index c54a2f2ac..b16ba5a34 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ opensrc/ # Migration exports (contain PII — user emails, IDs, avatars) apps/backend/exports/ +.alchemy/ diff --git a/alchemy.run.ts b/alchemy.run.ts new file mode 100644 index 000000000..1d8436cb3 --- /dev/null +++ b/alchemy.run.ts @@ -0,0 +1,133 @@ +// The Hazel stack. Each app declares its resources in `apps//alchemy.run.ts`; this file +// builds the deploy context (`HazelStack`) and yields them in dependency order. +// Plan: infra/cloudflare-migration-plan.md +import { appendFileSync } from "node:fs" +import * as Alchemy from "alchemy" +import * as Cloudflare from "alchemy/Cloudflare" +import * as Planetscale from "alchemy/Planetscale" +import { ConfigError } from "effect/Config" +import { SourceError } from "effect/ConfigProvider" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import { + declareHazelDb, + formatHazelStage, + HazelStack, + type HazelStackContext, + parseHazelStageEffect, + resolveHazelDomains, +} from "@hazel/infra/cloudflare" +import { plainWithDefault } from "@hazel/infra/env" +import Actors from "./apps/actors/alchemy.run.ts" +import ApiLive, { Api } from "./apps/backend/src/worker.ts" +import BotGateway from "./apps/bot-gateway/alchemy.run.ts" +import Docs from "./apps/docs/alchemy.run.ts" +import ElectricProxy from "./apps/electric-proxy/alchemy.run.ts" +import Landing from "./apps/landing/alchemy.run.ts" +import LinkPreview from "./apps/link-preview-worker/alchemy.run.ts" +import Web from "./apps/web/alchemy.run.ts" + +// Some secret stores define CLOUDFLARE_DEFAULT_ACCOUNT_ID; alchemy reads CLOUDFLARE_ACCOUNT_ID. +if (!process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID) { + process.env.CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID +} + +/** `alchemy dev` sets ALCHEMY_DEV on its exec child. Not stage-derived: a dev stage can be deployed. */ +const isDevServer = process.env.ALCHEMY_DEV === "true" + +// Inter-app URLs must be plain strings at plan time (`worker.url` is a lazy Output), so +// deployed stages use custom domains and dev stages fall back to env-supplied URLs. +const resolveUrl = (domain: string | undefined, envKey: string, fallback: string) => + domain + ? Effect.succeed(`https://${domain}`) + : Effect.map(plainWithDefault(envKey, fallback), (record) => record[envKey] ?? fallback) + +const asConfigError = (error: { readonly message: string }) => + Effect.fail(new ConfigError(new SourceError({ message: error.message, cause: error }))) + +/** Append `key=value` lines to the GitHub Actions step-output file, if any. */ +const appendStepOutputs = (lines: string[]): void => { + const file = process.env.GITHUB_OUTPUT + if (file) appendFileSync(file, `${lines.join("\n")}\n`) +} + +const HazelStackLive = Layer.effect( + HazelStack, + Effect.gen(function* () { + const stage = yield* parseHazelStageEffect(yield* Alchemy.Stage) + const domains = resolveHazelDomains(stage) + const context: HazelStackContext = { + stage, + domains, + isDevServer, + db: yield* declareHazelDb(stage), + urls: { + web: yield* resolveUrl(domains.web, "HAZEL_WEB_URL", "http://localhost:3000"), + api: yield* resolveUrl(domains.api, "HAZEL_API_URL", "http://localhost:3003"), + electric: yield* resolveUrl(domains.electric, "HAZEL_ELECTRIC_URL", "http://localhost:8184"), + rivet: yield* resolveUrl(domains.rivet, "HAZEL_RIVET_URL", "http://localhost:6420"), + linkPreview: yield* resolveUrl( + domains.linkPreview, + "HAZEL_LINK_PREVIEW_URL", + "http://localhost:5471", + ), + }, + } + return context + }), +) + +export default Alchemy.Stack( + "hazel", + { + // PlanetScale's credential lookup runs when the layer is built; `alchemy dev` never needs it. + providers: isDevServer + ? Cloudflare.providers() + : Cloudflare.providers().pipe(Layer.provideMerge(Planetscale.providers())), + // ALCHEMY_LOCAL_STATE=1 uses .alchemy/ file state instead of the account-wide store. + state: process.env.ALCHEMY_LOCAL_STATE ? Alchemy.localState() : Cloudflare.state(), + }, + Effect.gen(function* () { + const { stage, domains, urls } = yield* HazelStack + + // Before api, which binds its BotGateway Durable Objects cross-script. + const botGateway = yield* BotGateway + // The Live layer registers the api Worker's Durable Object classes in its bundle. + const api = yield* Effect.provide(Api, ApiLive) + // Also yields the `electric` Worker (self-hosted Electric in a Container) on deployed stages. + const electricProxy = yield* ElectricProxy + const linkPreview = yield* LinkPreview + const actors = yield* Actors + const web = yield* Web + // Shared marketing/docs sites: prd only (previews and dev run their own dev servers). + const landing = stage.kind === "prd" ? yield* Landing : undefined + const docs = stage.kind === "prd" ? yield* Docs : undefined + + const summary = { + stage: formatHazelStage(stage), + webUrl: domains.web ? `https://${domains.web}` : "", + apiUrl: urls.api, + electricUrl: urls.electric, + } + yield* Effect.sync(() => + appendStepOutputs([`web_url=${summary.webUrl}`, `api_url=${summary.apiUrl}`]), + ) + + return { + ...summary, + apiWorker: api.workerName, + botGatewayWorker: botGateway.workerName, + electricProxyWorker: electricProxy.workerName, + webWorker: web.workerName, + linkPreviewWorker: linkPreview.workerName, + actorsWorker: actors.workerName, + landingWorker: landing?.workerName, + docsWorker: docs?.workerName, + } + }).pipe( + // The stack IS the entry point: the one place `HazelStack` is provided. + Effect.provide(HazelStackLive), + // `Alchemy.Stack` admits only `ConfigError`. + Effect.catchTags({ "@hazel/infra/HazelStageError": asConfigError }), + ), +) diff --git a/apps/actors/alchemy.run.ts b/apps/actors/alchemy.run.ts new file mode 100644 index 000000000..1df82e328 --- /dev/null +++ b/apps/actors/alchemy.run.ts @@ -0,0 +1,42 @@ +/** + * RivetKit actors: an async Worker whose `ActorHandler` Durable Object class comes from + * `@rivetkit/cloudflare-workers`. On the adopting deploy the binding must keep the existing + * `ACTOR_DO` -> `ActorHandler` mapping (alchemy matches the live class by binding name). + */ +import { HazelStack, hazelWorkerProps, stageProps } from "@hazel/infra/cloudflare" +import * as Cloudflare from "alchemy/Cloudflare" +import { Effect } from "effect" + +export const ActorKv = Cloudflare.KV.Namespace( + "actor-kv", + stageProps("actor-kv", (name, stage) => ({ title: stage.kind === "prd" ? "hazel-actor-kv" : name })), +) + +export default Effect.gen(function* () { + const stack = yield* HazelStack + const actorKv = yield* ActorKv + return yield* Cloudflare.Worker("actors", { + ...hazelWorkerProps("actors", stack), + main: new URL("./src/index.ts", import.meta.url).pathname, + workersDev: stack.stage.kind !== "prd", + domain: stack.domains.rivet, + env: { + ACTOR_DO: Cloudflare.DurableObject("ACTOR_DO", { className: "ActorHandler" }), + ACTOR_KV: actorKv, + NODE_ENV: stack.stage.kind === "dev" ? "development" : "production", + RIVET_PUBLIC_ENDPOINT: stack.urls.rivet, + }, + observability: { + enabled: true, + headSamplingRate: 1, + logs: { + enabled: true, + headSamplingRate: 1, + persist: true, + invocationLogs: true, + destinations: ["maple-logs"], + }, + traces: { enabled: true, persist: true, headSamplingRate: 1, destinations: ["maple-traces"] }, + }, + }) +}) diff --git a/apps/actors/package.json b/apps/actors/package.json index 82c911c2c..9e84cb376 100644 --- a/apps/actors/package.json +++ b/apps/actors/package.json @@ -5,7 +5,6 @@ "module": "src/index.ts", "scripts": { "dev": "wrangler dev --port=6420", - "deploy": "wrangler deploy", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/actors/wrangler.jsonc b/apps/actors/wrangler.jsonc index 18f0e3cc1..83d76bda2 100644 --- a/apps/actors/wrangler.jsonc +++ b/apps/actors/wrangler.jsonc @@ -1,3 +1,4 @@ +// Local `wrangler dev` only. Deploys go through alchemy: apps/actors/alchemy.run.ts { "name": "hazel-actors", "main": "src/index.ts", diff --git a/apps/backend/package.json b/apps/backend/package.json index 14f3362f0..36f2332f8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,7 +11,7 @@ "scripts": { "dev": "bun run --watch src/index.ts", "start": "bun run src/index.ts", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.worker.json", "test": "vitest run src/**/*.test.ts", "test:sync": "vitest run src/services/chat-sync/*.test.ts src/services/chat-sync/*.integration.test.ts src/services/message-*.test.ts src/test/message-outbox-repo.test.ts --exclude src/services/chat-sync/*.e2e.test.ts", "test:watch": "vitest src/**/*.test.ts", @@ -30,18 +30,25 @@ "@effect/sql-pg": "catalog:effect", "@hazel/auth": "workspace:*", "@hazel/backend-core": "workspace:*", + "@hazel/bot-gateway": "workspace:*", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/effect-bun": "workspace:*", + "@hazel/effect-cloudflare": "workspace:*", + "@hazel/infra": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", + "alchemy": "catalog:alchemy", + "aws4fetch": "^1.0.20", "dfx": "^1.1.0", "drizzle-orm": "^0.45.1", "effect": "catalog:effect", "jose": "^6.1.3", - "pg": "^8.16.3" + "pg": "^8.16.3", + "uuid": "^13" }, "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", "@testcontainers/postgresql": "^10.18.0", "@types/bun": "1.3.9", "drizzle-kit": "^0.31.8", diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts new file mode 100644 index 000000000..545ca14bb --- /dev/null +++ b/apps/backend/src/app.ts @@ -0,0 +1,208 @@ +/** + * The backend's runtime-agnostic application: routes plus every service that does not depend on + * the host platform. Entry points (`index.ts` on Bun, `worker.ts` on Cloudflare) provide the + * platform layer: `Database`, `Persistence`, `RateLimiter`, `BotGatewayTransport`, the tracer and + * the ConfigProvider. + */ +import { HttpApiScalar } from "effect/http-api" +import { FetchHttpClient, HttpRouter, HttpServerResponse } from "effect/http" +import { RpcSerialization, RpcServer } from "effect/rpc" +import { + AttachmentRepo, + BotCommandRepo, + BotInstallationRepo, + BotRepo, + ChannelMemberRepo, + ChannelRepo, + ChannelSectionRepo, + ChatSyncChannelLinkRepo, + ChatSyncConnectionRepo, + ChatSyncEventReceiptRepo, + ChatSyncMessageLinkRepo, + ConnectConversationChannelRepo, + ConnectConversationRepo, + ConnectInviteRepo, + ConnectParticipantRepo, + CustomEmojiRepo, + ChannelWebhookRepo, + GitHubSubscriptionRepo, + IntegrationConnectionRepo, + IntegrationTokenRepo, + MessageReactionRepo, + MessageOutboxRepo, + MessageRepo, + NotificationRepo, + OrganizationMemberRepo, + OrganizationRepo, + PinnedMessageRepo, + RssSubscriptionRepo, + TypingIndicatorRepo, + UserPresenceStatusRepo, + UserRepo, + ClerkSync, +} from "@hazel/backend-core" +import { ClerkClient } from "@hazel/auth" +import { GitHub } from "@hazel/integrations" +import { Layer } from "effect" +import { HazelApi } from "./api" +import { HttpApiRoutes } from "./http" +import { AttachmentPolicy } from "./policies/attachment-policy" +import { BotPolicy } from "./policies/bot-policy" +import { ChannelMemberPolicy } from "./policies/channel-member-policy" +import { ChannelPolicy } from "./policies/channel-policy" +import { ChannelSectionPolicy } from "./policies/channel-section-policy" +import { CustomEmojiPolicy } from "./policies/custom-emoji-policy" +import { ChannelWebhookPolicy } from "./policies/channel-webhook-policy" +import { GitHubSubscriptionPolicy } from "./policies/github-subscription-policy" +import { RssSubscriptionPolicy } from "./policies/rss-subscription-policy" +import { IntegrationConnectionPolicy } from "./policies/integration-connection-policy" +import { MessagePolicy } from "./policies/message-policy" +import { MessageReactionPolicy } from "./policies/message-reaction-policy" +import { NotificationPolicy } from "./policies/notification-policy" +import { OrganizationMemberPolicy } from "./policies/organization-member-policy" +import { OrganizationPolicy } from "./policies/organization-policy" +import { PinnedMessagePolicy } from "./policies/pinned-message-policy" +import { TypingIndicatorPolicy } from "./policies/typing-indicator-policy" +import { UserPolicy } from "./policies/user-policy" +import { UserPresenceStatusPolicy } from "./policies/user-presence-status-policy" +import { AllRpcs, RpcServerLive } from "./rpc/server" +import { AuthorizationLive } from "./services/auth" +import { IntegrationTokenService } from "./services/integration-token-service" +import { IntegrationBotService } from "./services/integrations/integration-bot-service" +import { ChatSyncAttributionReconciler } from "./services/chat-sync/chat-sync-attribution-reconciler" +import { DiscordSyncWorkerLayer } from "./services/chat-sync/discord-sync-worker" +import { MessageSideEffectService } from "./services/message-side-effect-service" +import { MockDataGenerator } from "./services/mock-data-generator" +import { OAuthBearerAuth } from "./services/oauth-bearer-auth" +import { ObjectStorage } from "./services/object-storage" +import { OAuthProviderRegistry } from "./services/oauth" +import { SessionManager } from "./services/session-manager" +import { WebhookBotService } from "./services/webhook-bot-service" +import { BotGatewayService } from "./services/bot-gateway-service" +import { ChannelAccessSyncService } from "./services/channel-access-sync" +import { ConnectConversationService } from "./services/connect-conversation-service" +import { OrgResolver } from "./services/org-resolver" + +export { HazelApi } + +// Export RPC groups for frontend consumption +export { AuthMiddleware, MessageRpcs, NotificationRpcs } from "@hazel/domain/rpc" + +export const HealthRouter = HttpRouter.use((router) => + router.add("GET", "/health", HttpServerResponse.text("OK")), +) + +const DocsRoute = HttpApiScalar.layer(HazelApi, { + path: "/docs", +}) + +// HTTP RPC endpoint +const RpcRoute = RpcServer.layerHttp({ + group: AllRpcs, + path: "/rpc", + protocol: "http", +}).pipe(Layer.provide(RpcSerialization.layerNdjson), Layer.provide(RpcServerLive)) + +export const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, RpcRoute).pipe( + Layer.provide( + HttpRouter.cors({ + allowedOrigins: [ + "http://localhost:3000", + "http://localhost:5173", + "https://app.hazel.sh", + "tauri://localhost", + "http://tauri.localhost", + ], + allowedMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + credentials: true, + }), + ), +) + +export const RepoLive = Layer.mergeAll( + MessageRepo.layer, + ChannelRepo.layer, + ChannelMemberRepo.layer, + ChannelSectionRepo.layer, + ChatSyncConnectionRepo.layer, + ChatSyncChannelLinkRepo.layer, + ChatSyncMessageLinkRepo.layer, + ChatSyncEventReceiptRepo.layer, + ConnectConversationRepo.layer, + ConnectConversationChannelRepo.layer, + ConnectInviteRepo.layer, + ConnectParticipantRepo.layer, + UserRepo.layer, + OrganizationRepo.layer, + OrganizationMemberRepo.layer, + PinnedMessageRepo.layer, + AttachmentRepo.layer, + NotificationRepo.layer, + TypingIndicatorRepo.layer, + MessageReactionRepo.layer, + MessageOutboxRepo.layer, + UserPresenceStatusRepo.layer, + IntegrationConnectionRepo.layer, + IntegrationTokenRepo.layer, + ChannelWebhookRepo.layer, + GitHubSubscriptionRepo.layer, + RssSubscriptionRepo.layer, + BotRepo.layer, + BotCommandRepo.layer, + BotInstallationRepo.layer, + CustomEmojiRepo.layer, +) + +export const PolicyLive = Layer.mergeAll( + OrgResolver.layer, + OrganizationPolicy.layer, + ChannelPolicy.layer, + ChannelSectionPolicy.layer, + MessagePolicy.layer, + OrganizationMemberPolicy.layer, + ChannelMemberPolicy.layer, + MessageReactionPolicy.layer, + UserPolicy.layer, + AttachmentPolicy.layer, + PinnedMessagePolicy.layer, + TypingIndicatorPolicy.layer, + NotificationPolicy.layer, + UserPresenceStatusPolicy.layer, + IntegrationConnectionPolicy.layer, + ChannelWebhookPolicy.layer, + GitHubSubscriptionPolicy.layer, + RssSubscriptionPolicy.layer, + BotPolicy.layer, + CustomEmojiPolicy.layer, +) + +/** + * Services shared by every runtime. Still requires the platform services (`Database`, + * `Persistence`, `RateLimiter`, `BotGatewayTransport`) from the entry point. + */ +export const AppServicesLive = Layer.mergeAll( + RepoLive, + PolicyLive, + MockDataGenerator.layer, + ClerkClient.layer, + ClerkSync.layer, + ObjectStorage.layer, + GitHub.GitHubAppJWTService.layer, + GitHub.GitHubApiClient.layer, + IntegrationTokenService.layer, + OAuthProviderRegistry.layer, + IntegrationBotService.layer, + ChatSyncAttributionReconciler.layer, + DiscordSyncWorkerLayer, + MessageSideEffectService.layer, + BotGatewayService.layer, + WebhookBotService.layer, + ChannelAccessSyncService.layer, + ConnectConversationService.layer, + // SessionManager.layer includes BackendAuth.layer via dependencies + SessionManager.layer, + OAuthBearerAuth.layer, +).pipe(Layer.provideMerge(FetchHttpClient.layer)) + +/** `CurrentUser` resolution for authenticated routes. Requires `Database` from the platform. */ +export const AppAuthorizationLive = AuthorizationLive.pipe(Layer.provideMerge(SessionManager.layer)) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 9558d61dc..6809cee28 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,214 +1,49 @@ -import { HttpApiScalar } from "effect/http-api" -import { FetchHttpClient, HttpRouter, HttpMiddleware, HttpServerResponse } from "effect/http" -import { BunHttpServer, BunRuntime } from "@effect/platform-bun" -import { RpcSerialization, RpcServer } from "effect/rpc" -import { - AttachmentRepo, - BotCommandRepo, - BotInstallationRepo, - BotRepo, - ChannelMemberRepo, - ChannelRepo, - ChannelSectionRepo, - ChatSyncChannelLinkRepo, - ChatSyncConnectionRepo, - ChatSyncEventReceiptRepo, - ChatSyncMessageLinkRepo, - ConnectConversationChannelRepo, - ConnectConversationRepo, - ConnectInviteRepo, - ConnectParticipantRepo, - CustomEmojiRepo, - ChannelWebhookRepo, - GitHubSubscriptionRepo, - IntegrationConnectionRepo, - IntegrationTokenRepo, - MessageReactionRepo, - MessageOutboxRepo, - MessageRepo, - NotificationRepo, - OrganizationMemberRepo, - OrganizationRepo, - PinnedMessageRepo, - RssSubscriptionRepo, - TypingIndicatorRepo, - UserPresenceStatusRepo, - UserRepo, - ClerkSync, -} from "@hazel/backend-core" -import { ClerkClient } from "@hazel/auth" -import { Redis, RedisResultPersistenceLive, S3 } from "@hazel/effect-bun" +/** + * Bun entry point (Railway). Kept runnable during the Cloudflare cutover; the Worker entry is + * `worker.ts`. See infra/cloudflare-migration-plan.md. + */ +import { BunHttpServer, BunRuntime, BunSocket } from "@effect/platform-bun" +import { Redis, RedisResultPersistenceLive } from "@hazel/effect-bun" import { createTracingLayer } from "@hazel/effect-bun/Telemetry" -import { GitHub } from "@hazel/integrations" -import { Config, ConfigProvider, Effect, Layer, Context } from "effect" -import { HazelApi } from "./api" -import { HttpApiRoutes } from "./http" -import { AttachmentPolicy } from "./policies/attachment-policy" -import { BotPolicy } from "./policies/bot-policy" -import { ChannelMemberPolicy } from "./policies/channel-member-policy" -import { ChannelPolicy } from "./policies/channel-policy" -import { ChannelSectionPolicy } from "./policies/channel-section-policy" -import { CustomEmojiPolicy } from "./policies/custom-emoji-policy" -import { ChannelWebhookPolicy } from "./policies/channel-webhook-policy" -import { GitHubSubscriptionPolicy } from "./policies/github-subscription-policy" -import { RssSubscriptionPolicy } from "./policies/rss-subscription-policy" -import { IntegrationConnectionPolicy } from "./policies/integration-connection-policy" -import { MessagePolicy } from "./policies/message-policy" -import { MessageReactionPolicy } from "./policies/message-reaction-policy" -import { NotificationPolicy } from "./policies/notification-policy" -import { OrganizationMemberPolicy } from "./policies/organization-member-policy" -import { OrganizationPolicy } from "./policies/organization-policy" -import { PinnedMessagePolicy } from "./policies/pinned-message-policy" -import { TypingIndicatorPolicy } from "./policies/typing-indicator-policy" -import { UserPolicy } from "./policies/user-policy" -import { UserPresenceStatusPolicy } from "./policies/user-presence-status-policy" -import { AllRpcs, RpcServerLive } from "./rpc/server" -import { AuthorizationLive } from "./services/auth" +import { Config, ConfigProvider, Layer } from "effect" +import { HttpMiddleware, HttpRouter } from "effect/http" +import { AllRoutes, AppAuthorizationLive, AppServicesLive, HazelApi } from "./app" +import { BotGatewayTransport } from "./services/bot-gateway-transport" +import { DiscordGatewayBackgroundLive } from "./services/chat-sync/discord-gateway-service" import { DatabaseLive } from "./services/database" -import { IntegrationTokenService } from "./services/integration-token-service" -import { IntegrationBotService } from "./services/integrations/integration-bot-service" -import { ChatSyncAttributionReconciler } from "./services/chat-sync/chat-sync-attribution-reconciler" -import { DiscordSyncWorkerLayer } from "./services/chat-sync/discord-sync-worker" -import { DiscordGatewayService } from "./services/chat-sync/discord-gateway-service" import { MessageOutboxDispatcher } from "./services/message-outbox-dispatcher" -import { MessageSideEffectService } from "./services/message-side-effect-service" -import { MockDataGenerator } from "./services/mock-data-generator" -import { OAuthBearerAuth } from "./services/oauth-bearer-auth" -import { OAuthProviderRegistry } from "./services/oauth" -import { RateLimiter } from "./services/rate-limiter" -import { SessionManager } from "./services/session-manager" -import { WebhookBotService } from "./services/webhook-bot-service" -import { BotGatewayService } from "./services/bot-gateway-service" -import { ChannelAccessSyncService } from "./services/channel-access-sync" -import { ConnectConversationService } from "./services/connect-conversation-service" -import { OrgResolver } from "./services/org-resolver" +import { RateLimiterRedisLive } from "./services/rate-limiter-redis" export { HazelApi } // Export RPC groups for frontend consumption export { AuthMiddleware, MessageRpcs, NotificationRpcs } from "@hazel/domain/rpc" -const HealthRouter = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK"))) - -const DocsRoute = HttpApiScalar.layer(HazelApi, { - path: "/docs", -}) - -// HTTP RPC endpoint -const RpcRoute = RpcServer.layerHttp({ - group: AllRpcs, - path: "/rpc", - protocol: "http", -}).pipe(Layer.provide(RpcSerialization.layerNdjson), Layer.provide(RpcServerLive)) - -const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, RpcRoute).pipe( - Layer.provide( - HttpRouter.cors({ - allowedOrigins: [ - "http://localhost:3000", - "http://localhost:5173", - "https://app.hazel.sh", - "tauri://localhost", - "http://tauri.localhost", - ], - allowedMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - credentials: true, - }), - ), -) - const TracerLive = createTracingLayer("api") -const RepoLive = Layer.mergeAll( - MessageRepo.layer, - ChannelRepo.layer, - ChannelMemberRepo.layer, - ChannelSectionRepo.layer, - ChatSyncConnectionRepo.layer, - ChatSyncChannelLinkRepo.layer, - ChatSyncMessageLinkRepo.layer, - ChatSyncEventReceiptRepo.layer, - ConnectConversationRepo.layer, - ConnectConversationChannelRepo.layer, - ConnectInviteRepo.layer, - ConnectParticipantRepo.layer, - UserRepo.layer, - OrganizationRepo.layer, - OrganizationMemberRepo.layer, - PinnedMessageRepo.layer, - AttachmentRepo.layer, - NotificationRepo.layer, - TypingIndicatorRepo.layer, - MessageReactionRepo.layer, - MessageOutboxRepo.layer, - UserPresenceStatusRepo.layer, - IntegrationConnectionRepo.layer, - IntegrationTokenRepo.layer, - ChannelWebhookRepo.layer, - GitHubSubscriptionRepo.layer, - RssSubscriptionRepo.layer, - BotRepo.layer, - BotCommandRepo.layer, - BotInstallationRepo.layer, - CustomEmojiRepo.layer, -) - -const PolicyLive = Layer.mergeAll( - OrgResolver.layer, - OrganizationPolicy.layer, - ChannelPolicy.layer, - ChannelSectionPolicy.layer, - MessagePolicy.layer, - OrganizationMemberPolicy.layer, - ChannelMemberPolicy.layer, - MessageReactionPolicy.layer, - UserPolicy.layer, - AttachmentPolicy.layer, - PinnedMessagePolicy.layer, - TypingIndicatorPolicy.layer, - NotificationPolicy.layer, - UserPresenceStatusPolicy.layer, - IntegrationConnectionPolicy.layer, - ChannelWebhookPolicy.layer, - GitHubSubscriptionPolicy.layer, - RssSubscriptionPolicy.layer, - BotPolicy.layer, - CustomEmojiPolicy.layer, -) - // ResultPersistence layer for session caching (uses Redis backing) const PersistenceLive = RedisResultPersistenceLive.pipe(Layer.provide(Redis.Default)) -const MainLive = Layer.mergeAll( - RepoLive, - PolicyLive, - MockDataGenerator.layer, - ClerkClient.layer, - ClerkSync.layer, +/** + * Bun's platform services: a pooled database, Redis-backed caches and rate limits, and bot gateway + * events appended to the Durable Streams server the Bun bot gateway reads. + */ +const PlatformLive = Layer.mergeAll( DatabaseLive, - S3.Default, - Redis.Default, PersistenceLive, - GitHub.GitHubAppJWTService.layer, - GitHub.GitHubApiClient.layer, - IntegrationTokenService.layer, - OAuthProviderRegistry.layer, - IntegrationBotService.layer, - ChatSyncAttributionReconciler.layer, - DiscordSyncWorkerLayer, - DiscordGatewayService.layer, - MessageSideEffectService.layer, + Redis.Default, + RateLimiterRedisLive, + BotGatewayTransport.layerDurableStreams, +) + +/** Long-running loops; on Cloudflare these are Durable Objects driven by crons. */ +const BackgroundLive = Layer.mergeAll( + DiscordGatewayBackgroundLive.pipe(Layer.provide(BunSocket.layerWebSocketConstructor)), MessageOutboxDispatcher.layer, - BotGatewayService.layer, - WebhookBotService.layer, - ChannelAccessSyncService.layer, - ConnectConversationService.layer, - RateLimiter.layer, - // SessionManager.layer includes BackendAuth.layer via dependencies - SessionManager.layer, - OAuthBearerAuth.layer, -).pipe( - Layer.provideMerge(FetchHttpClient.layer), +) + +const MainLive = Layer.mergeAll(AppServicesLive, BackgroundLive).pipe( + Layer.provideMerge(PlatformLive), Layer.provideMerge(ConfigProvider.layer(ConfigProvider.fromEnv({ preserveEmptyStrings: true }))), ) @@ -219,17 +54,9 @@ const ServerLayer = HttpRouter.serve(AllRoutes).pipe( (request) => request.url === "/health" || request.method === "OPTIONS", ), ), + Layer.provide(AppAuthorizationLive), Layer.provide(MainLive), Layer.provide(TracerLive), - Layer.provide( - AuthorizationLive.pipe( - // SessionManager.layer includes BackendAuth and UserRepo via dependencies - Layer.provideMerge(SessionManager.layer), - Layer.provideMerge(PersistenceLive), - Layer.provideMerge(Redis.Default), - Layer.provideMerge(DatabaseLive), - ), - ), Layer.provide( BunHttpServer.layerConfig( Config.all({ diff --git a/apps/backend/src/lib/cluster-client.ts b/apps/backend/src/lib/cluster-client.ts new file mode 100644 index 000000000..27a222609 --- /dev/null +++ b/apps/backend/src/lib/cluster-client.ts @@ -0,0 +1,22 @@ +import { Cluster } from "@hazel/domain" +import { Config, Effect, Option, Redacted } from "effect" +import { HttpClient, HttpClientRequest } from "effect/http" +import { HttpApiClient } from "effect/http-api" + +/** + * A client for the cluster's workflow API at `baseUrl`, sending the shared secret when + * `CLUSTER_API_SECRET` is set (the cluster rejects unauthenticated calls once it is). + */ +export const makeClusterClient = Effect.fn("makeClusterClient")(function* (baseUrl: string) { + const secret = yield* Config.option(Config.Redacted("CLUSTER_API_SECRET")).pipe(Effect.orDie) + return yield* HttpApiClient.make(Cluster.WorkflowApi, { + baseUrl, + transformClient: Option.match(secret, { + onNone: () => (client: HttpClient.HttpClient) => client, + onSome: (value) => + HttpClient.mapRequest( + HttpClientRequest.setHeader(Cluster.CLUSTER_API_SECRET_HEADER, Redacted.value(value)), + ), + }), + }) +}) diff --git a/apps/backend/src/routes/api-v1/integrations.http.ts b/apps/backend/src/routes/api-v1/integrations.http.ts index 92dfe78f4..9ce68a612 100644 --- a/apps/backend/src/routes/api-v1/integrations.http.ts +++ b/apps/backend/src/routes/api-v1/integrations.http.ts @@ -53,238 +53,236 @@ const buildWebhookUrl = (webhookId: string, token: string) => { return `${baseUrl}/webhooks/incoming/${webhookId}/${token}` } -export const HttpApiV1IntegrationsLive = HttpApiBuilder.group( - HazelApi, - "api-v1-integrations", - (handlers) => - Effect.gen(function* () { - const auth = yield* OAuthBearerAuth - const db = yield* Database.Database - const memberRepo = yield* OrganizationMemberRepo - const webhookPolicy = yield* ChannelWebhookPolicy +export const HttpApiV1IntegrationsLive = HttpApiBuilder.group(HazelApi, "api-v1-integrations", (handlers) => + Effect.gen(function* () { + const auth = yield* OAuthBearerAuth + const db = yield* Database.Database + const memberRepo = yield* OrganizationMemberRepo + const webhookPolicy = yield* ChannelWebhookPolicy - const authenticate = Effect.gen(function* () { - const token = yield* extractBearerToken - return yield* auth.authenticate(token) - }) + const authenticate = Effect.gen(function* () { + const token = yield* extractBearerToken + return yield* auth.authenticate(token) + }) - return handlers - .handle("listOrganizations", () => - Effect.gen(function* () { - const { currentUser } = yield* authenticate + return handlers + .handle("listOrganizations", () => + Effect.gen(function* () { + const { currentUser } = yield* authenticate - const rows = yield* db - .makeQuery((execute, userId: typeof currentUser.id) => - execute((client) => - client - .select({ - id: schema.organizationsTable.id, - name: schema.organizationsTable.name, - slug: schema.organizationsTable.slug, - logoUrl: schema.organizationsTable.logoUrl, - }) - .from(schema.organizationMembersTable) - .innerJoin( - schema.organizationsTable, - eq( - schema.organizationsTable.id, - schema.organizationMembersTable.organizationId, - ), - ) - .where( - and( - eq(schema.organizationMembersTable.userId, userId), - isNull(schema.organizationMembersTable.deletedAt), - isNull(schema.organizationsTable.deletedAt), - ), + const rows = yield* db + .makeQuery((execute, userId: typeof currentUser.id) => + execute((client) => + client + .select({ + id: schema.organizationsTable.id, + name: schema.organizationsTable.name, + slug: schema.organizationsTable.slug, + logoUrl: schema.organizationsTable.logoUrl, + }) + .from(schema.organizationMembersTable) + .innerJoin( + schema.organizationsTable, + eq( + schema.organizationsTable.id, + schema.organizationMembersTable.organizationId, + ), + ) + .where( + and( + eq(schema.organizationMembersTable.userId, userId), + isNull(schema.organizationMembersTable.deletedAt), + isNull(schema.organizationsTable.deletedAt), ), + ), + ), + )(currentUser.id) + .pipe( + Effect.catchTag("DatabaseError", (err) => + Effect.fail( + new InternalServerError({ + message: "Failed to list organizations", + detail: String(err), + }), ), - )(currentUser.id) - .pipe( - Effect.catchTag("DatabaseError", (err) => - Effect.fail( - new InternalServerError({ - message: "Failed to list organizations", - detail: String(err), - }), + ), + ) + + return new ApiV1OrganizationsListResponse({ + data: rows.map((row) => ({ + id: row.id, + name: row.name, + slug: row.slug, + logoUrl: row.logoUrl, + })), + }) + }), + ) + .handle("listChannels", ({ params }) => + Effect.gen(function* () { + const { currentUser } = yield* authenticate + + const membership = yield* memberRepo + .findByOrgAndUser(params.organizationId, currentUser.id) + .pipe( + Effect.catchTag("DatabaseError", (err) => + Effect.fail( + new InternalServerError({ + message: "Failed to verify organization membership", + detail: String(err), + }), + ), + ), + ) + + if (Option.isNone(membership)) { + return yield* Effect.fail( + new ApiV1OrganizationNotFoundError({ + organizationId: params.organizationId, + message: "Organization not found or you are not a member", + }), + ) + } + + const rows = yield* db + .makeQuery((execute, orgId: typeof params.organizationId) => + execute((client) => + client + .select({ + id: schema.channelsTable.id, + name: schema.channelsTable.name, + type: schema.channelsTable.type, + organizationId: schema.channelsTable.organizationId, + }) + .from(schema.channelsTable) + .where( + and( + eq(schema.channelsTable.organizationId, orgId), + isNull(schema.channelsTable.deletedAt), + ), ), + ), + )(params.organizationId) + .pipe( + Effect.catchTag("DatabaseError", (err) => + Effect.fail( + new InternalServerError({ + message: "Failed to list channels", + detail: String(err), + }), ), - ) + ), + ) - return new ApiV1OrganizationsListResponse({ - data: rows.map((row) => ({ + return new ApiV1ChannelsListResponse({ + data: rows + .filter((row) => row.type === "public" || row.type === "private") + .map((row) => ({ id: row.id, name: row.name, - slug: row.slug, - logoUrl: row.logoUrl, + type: row.type, + organizationId: row.organizationId, })), - }) - }), - ) - .handle("listChannels", ({ params }) => + }) + }), + ) + .handle("createChannelWebhook", ({ payload }) => + withHttpScopes( + REQUIRED_SCOPES, Effect.gen(function* () { const { currentUser } = yield* authenticate + const channelRepo = yield* ChannelRepo + const webhookRepo = yield* ChannelWebhookRepo + const webhookBotService = yield* WebhookBotService + const integrationBotService = yield* IntegrationBotService - const membership = yield* memberRepo - .findByOrgAndUser(params.organizationId, currentUser.id) - .pipe( - Effect.catchTag("DatabaseError", (err) => - Effect.fail( - new InternalServerError({ - message: "Failed to verify organization membership", - detail: String(err), + const result = yield* db + .transaction( + Effect.gen(function* () { + const channelOption = yield* channelRepo.findById(payload.channelId) + if (Option.isNone(channelOption)) { + return yield* Effect.fail( + new ApiV1ChannelNotFoundError({ + channelId: payload.channelId, + message: "Channel not found", + }), + ) + } + const channel = channelOption.value + + const { token, tokenHash, tokenSuffix } = generateWebhookToken() + + const botUser = yield* Option.fromNullishOr( + payload.integrationProvider, + ).pipe( + Option.match({ + onNone: () => { + const botReferenceId = crypto.randomUUID() as ChannelWebhookId + return webhookBotService.createWebhookBot( + botReferenceId, + payload.name, + payload.avatarUrl ?? null, + channel.organizationId, + ) + }, + onSome: (provider) => + integrationBotService.getOrCreateWebhookBotUser( + provider, + channel.organizationId, + ), }), - ), - ), - ) + ) + + yield* webhookPolicy.canCreate(payload.channelId) - if (Option.isNone(membership)) { - return yield* Effect.fail( - new ApiV1OrganizationNotFoundError({ - organizationId: params.organizationId, - message: "Organization not found or you are not a member", + const [webhook] = yield* webhookRepo.insert({ + channelId: payload.channelId, + organizationId: channel.organizationId, + botUserId: botUser.id, + name: payload.name, + description: payload.description ?? null, + avatarUrl: payload.avatarUrl ?? null, + tokenHash, + tokenSuffix, + isEnabled: true, + createdBy: currentUser.id, + lastUsedAt: null, + deletedAt: null, + }) + + return { webhook, token } }), ) - } - - const rows = yield* db - .makeQuery((execute, orgId: typeof params.organizationId) => - execute((client) => - client - .select({ - id: schema.channelsTable.id, - name: schema.channelsTable.name, - type: schema.channelsTable.type, - organizationId: schema.channelsTable.organizationId, - }) - .from(schema.channelsTable) - .where( - and( - eq(schema.channelsTable.organizationId, orgId), - isNull(schema.channelsTable.deletedAt), - ), - ), - ), - )(params.organizationId) .pipe( - Effect.catchTag("DatabaseError", (err) => - Effect.fail( - new InternalServerError({ - message: "Failed to list channels", - detail: String(err), - }), - ), - ), + Effect.catchTags({ + DatabaseError: (err) => + Effect.fail( + new InternalServerError({ + message: "Failed to create channel webhook", + detail: String(err), + }), + ), + SchemaError: (err) => + Effect.fail( + new InternalServerError({ + message: "Schema validation failed", + detail: String(err), + }), + ), + }), + Effect.provideService(CurrentUser.Context, currentUser), ) - return new ApiV1ChannelsListResponse({ - data: rows - .filter((row) => row.type === "public" || row.type === "private") - .map((row) => ({ - id: row.id, - name: row.name, - type: row.type, - organizationId: row.organizationId, - })), + return new ApiV1ChannelWebhookCreatedResponse({ + id: result.webhook.id, + channelId: result.webhook.channelId, + organizationId: result.webhook.organizationId, + name: result.webhook.name, + webhookUrl: buildWebhookUrl(result.webhook.id, result.token), + token: result.token, }) }), - ) - .handle("createChannelWebhook", ({ payload }) => - withHttpScopes( - REQUIRED_SCOPES, - Effect.gen(function* () { - const { currentUser } = yield* authenticate - const channelRepo = yield* ChannelRepo - const webhookRepo = yield* ChannelWebhookRepo - const webhookBotService = yield* WebhookBotService - const integrationBotService = yield* IntegrationBotService - - const result = yield* db - .transaction( - Effect.gen(function* () { - const channelOption = yield* channelRepo.findById(payload.channelId) - if (Option.isNone(channelOption)) { - return yield* Effect.fail( - new ApiV1ChannelNotFoundError({ - channelId: payload.channelId, - message: "Channel not found", - }), - ) - } - const channel = channelOption.value - - const { token, tokenHash, tokenSuffix } = generateWebhookToken() - - const botUser = yield* Option.fromNullishOr(payload.integrationProvider).pipe( - Option.match({ - onNone: () => { - const botReferenceId = - crypto.randomUUID() as ChannelWebhookId - return webhookBotService.createWebhookBot( - botReferenceId, - payload.name, - payload.avatarUrl ?? null, - channel.organizationId, - ) - }, - onSome: (provider) => - integrationBotService.getOrCreateWebhookBotUser( - provider, - channel.organizationId, - ), - }), - ) - - yield* webhookPolicy.canCreate(payload.channelId) - - const [webhook] = yield* webhookRepo.insert({ - channelId: payload.channelId, - organizationId: channel.organizationId, - botUserId: botUser.id, - name: payload.name, - description: payload.description ?? null, - avatarUrl: payload.avatarUrl ?? null, - tokenHash, - tokenSuffix, - isEnabled: true, - createdBy: currentUser.id, - lastUsedAt: null, - deletedAt: null, - }) - - return { webhook, token } - }), - ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new InternalServerError({ - message: "Failed to create channel webhook", - detail: String(err), - }), - ), - SchemaError: (err) => - Effect.fail( - new InternalServerError({ - message: "Schema validation failed", - detail: String(err), - }), - ), - }), - Effect.provideService(CurrentUser.Context, currentUser), - ) - - return new ApiV1ChannelWebhookCreatedResponse({ - id: result.webhook.id, - channelId: result.webhook.channelId, - organizationId: result.webhook.organizationId, - name: result.webhook.name, - webhookUrl: buildWebhookUrl(result.webhook.id, result.token), - token: result.token, - }) - }), - ), - ) - }), + ), + ) + }), ) diff --git a/apps/backend/src/routes/bot-commands.http.test.ts b/apps/backend/src/routes/bot-commands.http.test.ts index cd0bf1d8a..62a222d93 100644 --- a/apps/backend/src/routes/bot-commands.http.test.ts +++ b/apps/backend/src/routes/bot-commands.http.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest" import { Effect, Fiber, Stream } from "effect" -import { createCommandSseStream, createSseHeartbeatStream, type CommandSseRedis } from "./bot-commands.sse.ts" +import { createSseHeartbeatStream } from "./bot-commands.sse.ts" describe("bot command SSE streams", () => { it("emits an immediate heartbeat on connect", () => @@ -30,43 +30,4 @@ describe("bot command SSE streams", () => { expect(event).toContain("event: heartbeat") } }).pipe(Effect.runPromise)) - - it("passes command events through unchanged", () => - Effect.gen(function* () { - const payload = JSON.stringify({ - type: "command", - commandName: "issue", - channelId: "ch_123", - userId: "usr_456", - orgId: "org_789", - arguments: { title: "Bug" }, - timestamp: Date.now(), - }) - - const redisMock: CommandSseRedis = { - subscribe: (channel: string, handler: (message: string, chan: string) => void) => - Effect.sync(() => { - queueMicrotask(() => { - handler(payload, channel) - }) - return { unsubscribe: Effect.void } - }), - } - - const stream = createCommandSseStream({ - botId: "bot_test", - botName: "Test Bot", - channel: "bot:bot_test:commands", - redis: redisMock, - heartbeatInterval: "1 hour", - }) - - const collector = yield* stream.pipe(Stream.take(2), Stream.runCollect, Effect.forkDetach) - - const events = Array.from(yield* Fiber.join(collector)) as string[] - const commandEvent = events.find((event) => event.includes("event: command")) - - expect(commandEvent).toBeDefined() - expect(commandEvent).toContain(`data: ${payload}`) - }).pipe(Effect.runPromise)) }) diff --git a/apps/backend/src/routes/bot-commands.http.ts b/apps/backend/src/routes/bot-commands.http.ts index 2dc118024..e042954bb 100644 --- a/apps/backend/src/routes/bot-commands.http.ts +++ b/apps/backend/src/routes/bot-commands.http.ts @@ -16,12 +16,11 @@ import { SyncBotCommandsResponse, UpdateBotSettingsResponse, } from "@hazel/domain/http" -import { Redis } from "@hazel/effect-bun" import { Cause, Effect, Option, Stream } from "effect" import { HazelApi } from "../api.ts" import { BotGatewayService } from "../services/bot-gateway-service.ts" import { IntegrationTokenService } from "../services/integration-token-service.ts" -import { createCommandSseStream } from "./bot-commands.sse.ts" +import { createSseHeartbeatStream } from "./bot-commands.sse.ts" /** * Hash a token using SHA-256 (Web Crypto API) @@ -76,21 +75,11 @@ export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands" // Validate bot token const bot = yield* validateBotToken - const redis = yield* Redis - const channel = `bot:${bot.id}:commands` - yield* Effect.logInfo(`Bot ${bot.id} (${bot.name}) connecting to SSE stream`) - // Merge command events with keepalive heartbeat events so idle connections stay active. - const sseStream = createCommandSseStream({ - botId: bot.id, - botName: bot.name, - channel, - redis, - }).pipe( - Stream.tap(() => Effect.logDebug("Sending SSE event")), - Stream.encodeText, - ) + // Commands are delivered through the bot gateway (`BotGatewayService.publishCommand`); + // this stream only keeps legacy SSE clients' connections alive. + const sseStream = createSseHeartbeatStream().pipe(Stream.encodeText) // Return SSE response return HttpServerResponse.stream(sseStream, { diff --git a/apps/backend/src/routes/bot-commands.sse.ts b/apps/backend/src/routes/bot-commands.sse.ts index 966580d9c..90a29ae48 100644 --- a/apps/backend/src/routes/bot-commands.sse.ts +++ b/apps/backend/src/routes/bot-commands.sse.ts @@ -1,5 +1,5 @@ import { Sse } from "effect/encoding" -import { Duration, Effect, Queue, Schedule, Stream } from "effect" +import { Duration, Schedule, Stream } from "effect" const HEARTBEAT_INTERVAL = "25 seconds" as const @@ -11,18 +11,6 @@ const encodeSseEvent = (event: string, data: string) => data, }) -export type CommandSseRedis = { - readonly subscribe: ( - channel: string, - handler: (message: string, channel: string) => void, - ) => Effect.Effect< - { - readonly unsubscribe: Effect.Effect - }, - unknown - > -} - export const createSseHeartbeatStream = (interval: Duration.Input = HEARTBEAT_INTERVAL) => Stream.make( encodeSseEvent( @@ -47,47 +35,3 @@ export const createSseHeartbeatStream = (interval: Duration.Input = HEARTBEAT_IN ), ), ) - -interface CommandSseStreamOptions { - readonly botId: string - readonly botName: string - readonly channel: string - readonly redis: CommandSseRedis - readonly heartbeatInterval?: Duration.Input -} - -export const createCommandSseStream = ({ - botId, - botName, - channel, - redis, - heartbeatInterval = HEARTBEAT_INTERVAL, -}: CommandSseStreamOptions) => { - const commandStream = Stream.callback((queue) => - Effect.gen(function* () { - const { unsubscribe } = yield* redis.subscribe(channel, (message) => { - Queue.offerUnsafe(queue, encodeSseEvent("command", message)) - }) - - yield* Effect.addFinalizer(() => - unsubscribe.pipe( - Effect.tap(() => - Effect.logDebug(`Bot ${botId} (${botName}) disconnected from SSE stream`), - ), - Effect.catch(() => Effect.void), - ), - ) - - yield* Effect.never - }).pipe( - Effect.catch((error) => { - Effect.runFork(Effect.logError("Redis subscription error", { error, botId, botName })) - return Queue.end(queue) - }), - ), - ) - - return Stream.merge(commandStream, createSseHeartbeatStream(heartbeatInterval), { - haltStrategy: "either", - }) -} diff --git a/apps/backend/src/routes/uploads.http.ts b/apps/backend/src/routes/uploads.http.ts index d5ba7539f..94a82b5b3 100644 --- a/apps/backend/src/routes/uploads.http.ts +++ b/apps/backend/src/routes/uploads.http.ts @@ -13,12 +13,12 @@ import { UploadError, } from "@hazel/domain/http" import { AttachmentId } from "@hazel/schema" -import { S3 } from "@hazel/effect-bun" -import { randomUUIDv7 } from "bun" +import { v7 as randomUUIDv7 } from "uuid" import { Effect, Match, Option } from "effect" import { HazelApi } from "../api" import { AttachmentPolicy } from "../policies/attachment-policy" import { OrganizationPolicy } from "../policies/organization-policy" +import { ObjectStorage } from "../services/object-storage" import { checkAvatarRateLimit } from "../services/rate-limit-helpers" /** @@ -39,7 +39,7 @@ const makePresignUploadResponse = (input: { export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handlers) => Effect.gen(function* () { const db = yield* Database.Database - const s3 = yield* S3 + const storage = yield* ObjectStorage const attachmentPolicy = yield* AttachmentPolicy const organizationPolicy = yield* OrganizationPolicy const attachmentRepo = yield* AttachmentRepo @@ -80,13 +80,8 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle `Generating presigned URL for user avatar upload: ${key} (size: ${req.fileSize} bytes, type: ${req.contentType})`, ) - const uploadUrl = yield* s3 - .presign(key, { - acl: "public-read", - method: "PUT", - type: req.contentType, - expiresIn: 300, // 5 minutes - }) + const uploadUrl = yield* storage + .presignPut(key, { contentType: req.contentType, expiresIn: 300 }) .pipe( Effect.mapError( (error) => @@ -160,13 +155,8 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle `Generating presigned URL for bot avatar upload: ${key} (size: ${req.fileSize} bytes, type: ${req.contentType})`, ) - const uploadUrl = yield* s3 - .presign(key, { - acl: "public-read", - method: "PUT", - type: req.contentType, - expiresIn: 300, // 5 minutes - }) + const uploadUrl = yield* storage + .presignPut(key, { contentType: req.contentType, expiresIn: 300 }) .pipe( Effect.mapError( (error) => @@ -237,13 +227,8 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle `Generating presigned URL for organization avatar upload: ${key} (size: ${req.fileSize} bytes, type: ${req.contentType})`, ) - const uploadUrl = yield* s3 - .presign(key, { - acl: "public-read", - method: "PUT", - type: req.contentType, - expiresIn: 300, // 5 minutes - }) + const uploadUrl = yield* storage + .presignPut(key, { contentType: req.contentType, expiresIn: 300 }) .pipe( Effect.mapError( (error) => @@ -304,13 +289,8 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle `Generating presigned URL for custom emoji upload: ${key} (size: ${req.fileSize} bytes, type: ${req.contentType})`, ) - const uploadUrl = yield* s3 - .presign(key, { - acl: "public-read", - method: "PUT", - type: req.contentType, - expiresIn: 300, // 5 minutes - }) + const uploadUrl = yield* storage + .presignPut(key, { contentType: req.contentType, expiresIn: 300 }) .pipe( Effect.mapError( (error) => @@ -372,13 +352,8 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle .pipe(withRemapDbErrors("AttachmentRepo", "create")) // Generate presigned URL - const uploadUrl = yield* s3 - .presign(attachmentId, { - acl: "public-read", - method: "PUT", - type: req.contentType, - expiresIn: 300, // 5 minutes - }) + const uploadUrl = yield* storage + .presignPut(attachmentId, { contentType: req.contentType, expiresIn: 300 }) .pipe( Effect.mapError( (error) => diff --git a/apps/backend/src/routes/webhooks.http.ts b/apps/backend/src/routes/webhooks.http.ts index cb7bcda28..86c8eef8c 100644 --- a/apps/backend/src/routes/webhooks.http.ts +++ b/apps/backend/src/routes/webhooks.http.ts @@ -1,13 +1,14 @@ import { createHmac, timingSafeEqual } from "node:crypto" import { verifyWebhook as verifyClerkWebhook } from "@clerk/backend/webhooks" -import { HttpApiBuilder, HttpApiClient } from "effect/http-api" +import { HttpApiBuilder } from "effect/http-api" import { HttpServerRequest } from "effect/http" -import { Cluster, InternalServerError, WorkflowInitializationError } from "@hazel/domain" +import { InternalServerError, WorkflowInitializationError } from "@hazel/domain" import { GitHubWebhookResponse, InvalidGitHubWebhookSignature } from "@hazel/domain/http" import { Config, Effect, pipe, Redacted } from "effect" import { HazelApi, InvalidWebhookSignature, WebhookResponse } from "../api" import { ClerkSync } from "@hazel/backend-core/services" import { ChannelAccessSyncService } from "../services/channel-access-sync" +import { makeClusterClient } from "../lib/cluster-client" export const HttpWebhookLive = HttpApiBuilder.group(HazelApi, "webhooks", (handlers) => handlers @@ -190,9 +191,7 @@ export const HttpWebhookLive = HttpApiBuilder.group(HazelApi, "webhooks", (handl }) const clusterUrl = yield* Config.String("CLUSTER_URL") - const client = yield* HttpApiClient.make(Cluster.WorkflowApi, { - baseUrl: clusterUrl, - }) + const client = yield* makeClusterClient(clusterUrl) const wrapWorkflowError = (label: string) => (err: unknown) => Effect.fail( diff --git a/apps/backend/src/rpc/handlers/channels.ts b/apps/backend/src/rpc/handlers/channels.ts index 96f43cdbb..e348e2e01 100644 --- a/apps/backend/src/rpc/handlers/channels.ts +++ b/apps/backend/src/rpc/handlers/channels.ts @@ -1,4 +1,3 @@ -import { HttpApiClient } from "effect/http-api" import { ChannelMemberRepo, ChannelRepo, @@ -8,7 +7,6 @@ import { } from "@hazel/backend-core" import { Database, schema } from "@hazel/db" import { - Cluster, CurrentUser, DmChannelAlreadyExistsError, InternalServerError, @@ -25,6 +23,7 @@ import { ChannelPolicy } from "../../policies/channel-policy" import { UserPolicy } from "../../policies/user-policy" import { BotGatewayService } from "../../services/bot-gateway-service" import { ChannelAccessSyncService } from "../../services/channel-access-sync" +import { makeClusterClient } from "../../lib/cluster-client" export const ChannelRpcLive = ChannelRpcs.toLayer( Effect.gen(function* () { @@ -487,9 +486,7 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( }), ), ) - const client = yield* HttpApiClient.make(Cluster.WorkflowApi, { - baseUrl: clusterUrl, - }) + const client = yield* makeClusterClient(clusterUrl) yield* client.workflows .ThreadNamingWorkflow({ diff --git a/apps/backend/src/services/bot-gateway-service.test.ts b/apps/backend/src/services/bot-gateway-service.test.ts index fb87e66e2..4c9e2ea0f 100644 --- a/apps/backend/src/services/bot-gateway-service.test.ts +++ b/apps/backend/src/services/bot-gateway-service.test.ts @@ -3,9 +3,11 @@ import { BotInstallationRepo, ChannelRepo } from "@hazel/backend-core" import { createBotGatewayPartitionKey } from "@hazel/domain" import { Message } from "@hazel/domain/models" import type { BotId, ChannelId, MessageId, OrganizationId, UserId } from "@hazel/schema" -import { Effect, Layer, Option, Schema } from "effect" +import { BotGatewayEventRejectedError } from "@hazel/domain" +import { Effect, Layer, Option, Result, Schema } from "effect" import { configLayer, serviceShape } from "../test/effect-helpers" import { BotGatewayService } from "./bot-gateway-service" +import { BotGatewayTransport, type BotGatewayNamespace } from "./bot-gateway-transport" const DURABLE_STREAMS_URL = "http://durable.test/v1/stream" const BOT_ID = "00000000-0000-4000-8000-000000000111" as BotId @@ -41,14 +43,45 @@ const makeChannelRepoLayer = (organizationId: OrganizationId) => }), ) -const makeServiceLayer = (botIds: ReadonlyArray) => +const makeServiceLayer = ( + botIds: ReadonlyArray, + transport: Layer.Layer = BotGatewayTransport.layerDurableStreams.pipe( + Layer.provide(TestConfigLive), + Layer.orDie, + ), +) => Layer.effect(BotGatewayService, BotGatewayService.make).pipe( Layer.provide(makeBotInstallationRepoLayer(botIds)), Layer.provide(makeChannelRepoLayer(ORG_ID)), - Layer.provide(TestConfigLive), + Layer.provide(transport), ) -describe("BotGatewayService", () => { +/** A `BotGateway` namespace that records every publish, optionally rejecting them. */ +const makeRecordingNamespace = (options?: { readonly reject?: boolean }) => { + const published: Array<{ botId: string; eventJson: string }> = [] + const namespace: BotGatewayNamespace = { + getByName: (botId) => ({ + publish: (eventJson) => { + published.push({ botId, eventJson }) + return options?.reject + ? Effect.fail(new BotGatewayEventRejectedError({ message: "rejected" })) + : Effect.succeed({ offset: String(published.length).padStart(16, "0") }) + }, + }), + } + return { namespace, published } +} + +const COMMAND_PAYLOAD = { + commandName: "echo", + channelId: CHANNEL_ID, + userId: USER_ID, + orgId: ORG_ID, + arguments: { text: "hello" }, + timestamp: 1_700_000_000_000, +} + +describe("BotGatewayService (Durable Streams transport)", () => { it("ensures the stream and appends command events before returning", () => { const originalFetch = globalThis.fetch const requests: Array<{ url: string; method: string; body: string | null }> = [] @@ -169,3 +202,88 @@ describe("BotGatewayService", () => { ) }) }) + +describe("BotGatewayService (Durable Object transport)", () => { + it("publishes the command envelope as JSON to the bot's object", () => { + const { namespace, published } = makeRecordingNamespace() + return Effect.runPromise( + Effect.gen(function* () { + const gateway = yield* BotGatewayService + yield* gateway.publishCommand(BOT_ID, COMMAND_PAYLOAD) + + expect(published).toHaveLength(1) + expect(published[0]!.botId).toBe(BOT_ID) + const body = JSON.parse(published[0]!.eventJson) + expect(body.schemaVersion).toBe(1) + expect(body.eventType).toBe("command.invoke") + expect(body.partitionKey).toBe( + createBotGatewayPartitionKey({ + organizationId: ORG_ID, + channelId: CHANNEL_ID, + botId: BOT_ID, + }), + ) + expect(body.payload).toEqual(COMMAND_PAYLOAD) + }).pipe( + Effect.provide(makeServiceLayer([BOT_ID], BotGatewayTransport.layerDurableObject(namespace))), + ), + ) + }) + + it("fans message events out to one object per installed bot", () => { + const { namespace, published } = makeRecordingNamespace() + const message = { + id: MESSAGE_ID, + channelId: CHANNEL_ID, + conversationId: null, + authorId: USER_ID, + content: "hello from hazel", + embeds: null, + replyToMessageId: null, + threadChannelId: null, + createdAt: new Date("2026-03-05T12:00:00.000Z"), + updatedAt: null, + deletedAt: null, + } satisfies Schema.Schema.Type + + return Effect.runPromise( + Effect.gen(function* () { + const gateway = yield* BotGatewayService + yield* gateway.publishMessageEvent("message.create", message) + + expect(published.map((entry) => entry.botId).sort()).toEqual([BOT_ID, SECOND_BOT_ID]) + for (const entry of published) { + const body = JSON.parse(entry.eventJson) + expect(body.eventType).toBe("message.create") + // Same wire form as the Durable Streams transport: dates as ISO strings. + expect(body.payload.createdAt).toBe("2026-03-05T12:00:00.000Z") + } + }).pipe( + Effect.provide( + makeServiceLayer( + [BOT_ID, SECOND_BOT_ID], + BotGatewayTransport.layerDurableObject(namespace), + ), + ), + ), + ) + }) + + it("surfaces a rejected publish as DurableStreamRequestError", () => { + const { namespace } = makeRecordingNamespace({ reject: true }) + return Effect.runPromise( + Effect.gen(function* () { + const gateway = yield* BotGatewayService + const result = yield* Effect.result(gateway.publishCommand(BOT_ID, COMMAND_PAYLOAD)) + + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure._tag).toBe("DurableStreamRequestError") + expect(result.failure.cause).toBeInstanceOf(BotGatewayEventRejectedError) + } + }).pipe( + Effect.provide(makeServiceLayer([BOT_ID], BotGatewayTransport.layerDurableObject(namespace))), + ), + ) + }) +}) diff --git a/apps/backend/src/services/bot-gateway-service.ts b/apps/backend/src/services/bot-gateway-service.ts index 339b74ded..f504ffbc5 100644 --- a/apps/backend/src/services/bot-gateway-service.ts +++ b/apps/backend/src/services/bot-gateway-service.ts @@ -6,119 +6,24 @@ import { } from "@hazel/domain" import type { Channel, ChannelMember, Message } from "@hazel/domain/models" import type { BotId, ChannelId, OrganizationId } from "@hazel/schema" -import { Context, Config, DateTime, Effect, Layer, Option, Ref, Schema } from "effect" +import { Context, DateTime, Effect, Layer, type Schema } from "effect" +import { BotGatewayTransport, DurableStreamRequestError } from "./bot-gateway-transport" -const DEFAULT_DURABLE_STREAMS_URL = "http://localhost:4437/v1/stream" +export { BotGatewayTransport, DurableStreamRequestError } /** Get epoch milliseconds from Date or DateTime.Utc */ const toEpochMs = (d: Date | DateTime.Utc): number => d instanceof Date ? d.getTime() : DateTime.toEpochMillis(d) -const normalizeBaseUrl = (value: string): string => value.replace(/\/+$/, "") - const createDeliveryId = (): string => crypto.randomUUID() -const buildStreamPath = (baseUrl: string, botId: BotId): string => - `${normalizeBaseUrl(baseUrl)}/bots/${botId}/gateway` - -const responseText = (response: Response): Promise => - response.text().catch(() => `${response.status} ${response.statusText}`) - -export class DurableStreamRequestError extends Schema.TaggedError()( - "DurableStreamRequestError", - { - message: Schema.String, - cause: Schema.Unknown, - }, -) {} - export class BotGatewayService extends Context.Service()("BotGatewayService", { make: Effect.gen(function* () { const installationRepo = yield* BotInstallationRepo const channelRepo = yield* ChannelRepo - const durableStreamsUrl = yield* Config.String("DURABLE_STREAMS_URL").pipe( - Config.withDefault(DEFAULT_DURABLE_STREAMS_URL), - ) - const durableStreamsToken = yield* Config.option(Config.String("DURABLE_STREAMS_TOKEN")) - const authHeaders: Record = Option.isSome(durableStreamsToken) - ? { Authorization: `Bearer ${durableStreamsToken.value}` } - : {} - const ensuredStreamsRef = yield* Ref.make(new Set()) + const transport = yield* BotGatewayTransport - const ensureStream = Effect.fn("BotGatewayService.ensureStream")(function* (botId: BotId) { - const ensured = yield* Ref.get(ensuredStreamsRef) - if (ensured.has(botId)) { - return - } - - const url = buildStreamPath(durableStreamsUrl, botId) - const response = yield* Effect.tryPromise({ - try: () => - fetch(url, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeaders, - }, - }), - catch: (cause) => - new DurableStreamRequestError({ - message: `Failed to create durable stream for bot ${botId}`, - cause, - }), - }) - - if (!response.ok && response.status !== 409) { - const detail = yield* Effect.promise(() => responseText(response)) - return yield* Effect.fail( - new DurableStreamRequestError({ - message: `Failed to create durable stream for bot ${botId}: ${detail}`, - cause: response.status, - }), - ) - } - - yield* Ref.update(ensuredStreamsRef, (current) => { - const next = new Set(current) - next.add(botId) - return next - }) - }) - - const appendToBot = Effect.fn("BotGatewayService.appendToBot")(function* ( - botId: BotId, - envelope: BotGatewayEnvelope, - ) { - yield* ensureStream(botId) - - const url = buildStreamPath(durableStreamsUrl, botId) - const response = yield* Effect.tryPromise({ - try: () => - fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...authHeaders, - }, - body: JSON.stringify(envelope), - }), - catch: (cause) => - new DurableStreamRequestError({ - message: `Failed to append durable stream event for bot ${botId}`, - cause, - }), - }) - - if (!response.ok) { - const detail = yield* Effect.promise(() => responseText(response)) - return yield* Effect.fail( - new DurableStreamRequestError({ - message: `Failed to append durable stream event for bot ${botId}: ${detail}`, - cause: response.status, - }), - ) - } - }) + const appendToBot = (botId: BotId, envelope: BotGatewayEnvelope) => transport.append(botId, envelope) const publishToInstalledBots = Effect.fn("BotGatewayService.publishToInstalledBots")(function* ( organizationId: OrganizationId, @@ -263,37 +168,16 @@ export class BotGatewayService extends Context.Service()("Bot })) }) - const proxyRead = Effect.fn("BotGatewayService.proxyRead")(function* ( - botId: BotId, - query: URLSearchParams, - ) { - yield* ensureStream(botId) - - const url = new URL(buildStreamPath(durableStreamsUrl, botId)) - for (const [key, value] of query.entries()) { - url.searchParams.set(key, value) - } - - return yield* Effect.tryPromise({ - try: () => fetch(url.toString(), { method: "GET", headers: { ...authHeaders } }), - catch: (cause) => - new DurableStreamRequestError({ - message: `Failed to read durable stream for bot ${botId}`, - cause, - }), - }) - }) - return { appendToBot, publishCommand, publishMessageEvent, publishChannelEvent, publishChannelMemberEvent, - proxyRead, } }), }) { + /** Requires `BotGatewayTransport`, which the entry point provides for its runtime. */ static readonly layer = Layer.effect(this, this.make).pipe( Layer.provide(BotInstallationRepo.layer), Layer.provide(ChannelRepo.layer), diff --git a/apps/backend/src/services/bot-gateway-transport.ts b/apps/backend/src/services/bot-gateway-transport.ts new file mode 100644 index 000000000..3c7b405a4 --- /dev/null +++ b/apps/backend/src/services/bot-gateway-transport.ts @@ -0,0 +1,154 @@ +/** + * Where bot gateway events are appended, per runtime: + * + * - Bun (`index.ts`): `BotGatewayTransport.layerDurableStreams`, HTTP to the Durable Streams + * server the Bun bot gateway reads from (`DURABLE_STREAMS_URL`, `DURABLE_STREAMS_TOKEN`). + * - Cloudflare: `BotGatewayTransport.layerDurableObject(namespace)`, RPC into the bot's + * `BotGateway` Durable Object, hosted by the bot-gateway Worker (`apps/bot-gateway`). The backend + * Worker binds the namespace from its init (`bindBotGateways` from `@hazel/bot-gateway/object`). + * + * Both deliver the same JSON text bots decode, so the wire format does not depend on the transport. + */ +import type { BotGatewayEnvelope, BotGatewayRpc } from "@hazel/domain" +import type { BotId } from "@hazel/schema" +import { Config, Context, Effect, Layer, Option, Ref, Schema } from "effect" + +const DEFAULT_DURABLE_STREAMS_URL = "http://localhost:4437/v1/stream" + +/** + * A gateway append failed. The name predates the Durable Object transport; handlers catch it by + * this tag, so it is kept for both transports. + */ +export class DurableStreamRequestError extends Schema.TaggedError()( + "DurableStreamRequestError", + { + message: Schema.String, + cause: Schema.Unknown, + }, +) {} + +export interface BotGatewayTransportShape { + /** Durably append one envelope to the bot's event log; resolves once it is stored. */ + readonly append: ( + botId: BotId, + envelope: BotGatewayEnvelope, + ) => Effect.Effect +} + +/** + * The `BotGateway` namespace as the backend uses it: what alchemy's + * `BotGatewayObject.from(scriptName)` (or `bindBotGateways`) yields satisfies it. + */ +export interface BotGatewayNamespace { + readonly getByName: (botId: string) => BotGatewayRpc +} + +const normalizeBaseUrl = (value: string): string => value.replace(/\/+$/, "") + +const buildStreamPath = (baseUrl: string, botId: BotId): string => + `${normalizeBaseUrl(baseUrl)}/bots/${botId}/gateway` + +const responseText = (response: Response): Promise => + response.text().catch(() => `${response.status} ${response.statusText}`) + +const makeDurableStreamsTransport = Effect.gen(function* () { + const durableStreamsUrl = yield* Config.String("DURABLE_STREAMS_URL").pipe( + Config.withDefault(DEFAULT_DURABLE_STREAMS_URL), + ) + const durableStreamsToken = yield* Config.option(Config.String("DURABLE_STREAMS_TOKEN")) + const authHeaders: Record = Option.isSome(durableStreamsToken) + ? { Authorization: `Bearer ${durableStreamsToken.value}` } + : {} + const ensuredStreamsRef = yield* Ref.make(new Set()) + + const ensureStream = Effect.fn("BotGatewayTransport.ensureStream")(function* (botId: BotId) { + const ensured = yield* Ref.get(ensuredStreamsRef) + if (ensured.has(botId)) { + return + } + + const response = yield* Effect.tryPromise({ + try: () => + fetch(buildStreamPath(durableStreamsUrl, botId), { + method: "PUT", + headers: { "Content-Type": "application/json", ...authHeaders }, + }), + catch: (cause) => + new DurableStreamRequestError({ + message: `Failed to create durable stream for bot ${botId}`, + cause, + }), + }) + + if (!response.ok && response.status !== 409) { + const detail = yield* Effect.promise(() => responseText(response)) + return yield* new DurableStreamRequestError({ + message: `Failed to create durable stream for bot ${botId}: ${detail}`, + cause: response.status, + }) + } + + yield* Ref.update(ensuredStreamsRef, (current) => new Set(current).add(botId)) + }) + + const append = Effect.fn("BotGatewayTransport.append")(function* ( + botId: BotId, + envelope: BotGatewayEnvelope, + ) { + yield* ensureStream(botId) + + const response = yield* Effect.tryPromise({ + try: () => + fetch(buildStreamPath(durableStreamsUrl, botId), { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders }, + body: JSON.stringify(envelope), + }), + catch: (cause) => + new DurableStreamRequestError({ + message: `Failed to append durable stream event for bot ${botId}`, + cause, + }), + }) + + if (!response.ok) { + const detail = yield* Effect.promise(() => responseText(response)) + return yield* new DurableStreamRequestError({ + message: `Failed to append durable stream event for bot ${botId}: ${detail}`, + cause: response.status, + }) + } + }) + + return { append } satisfies BotGatewayTransportShape +}) + +const makeDurableObjectTransport = (namespace: BotGatewayNamespace): BotGatewayTransportShape => ({ + append: Effect.fn("BotGatewayTransport.append")(function* (botId: BotId, envelope: BotGatewayEnvelope) { + // `RpcCallError`s (the object or the RPC hop failed) arrive here too, untyped by the stub. + yield* namespace + .getByName(botId) + .publish(JSON.stringify(envelope)) + .pipe( + Effect.mapError( + (cause) => + new DurableStreamRequestError({ + message: `Failed to publish bot gateway event for bot ${botId}`, + cause, + }), + ), + ) + }), +}) + +/** Appends events to a bot's gateway log. Provided by the entry point; see the module comment. */ +export class BotGatewayTransport extends Context.Service()( + "BotGatewayTransport", +) { + /** Bun: the Durable Streams HTTP server (`DURABLE_STREAMS_URL`, `DURABLE_STREAMS_TOKEN`). */ + static readonly layerDurableStreams = Layer.effect(this, makeDurableStreamsTransport) + + /** Cloudflare Workers: RPC into each bot's `BotGateway` Durable Object. */ + static readonly layerDurableObject = (namespace: BotGatewayNamespace): Layer.Layer => + Layer.succeed(this, makeDurableObjectTransport(namespace)) +} diff --git a/apps/backend/src/services/channel-access-sync.ts b/apps/backend/src/services/channel-access-sync.ts index c9377511f..bc28ae0c9 100644 --- a/apps/backend/src/services/channel-access-sync.ts +++ b/apps/backend/src/services/channel-access-sync.ts @@ -2,7 +2,6 @@ import { and, eq, isNull, notInArray, schema } from "@hazel/db" import type { ChannelId, ConnectConversationId, OrganizationId, UserId } from "@hazel/schema" import { Context, Effect, Layer } from "effect" import { transactionAwareExecute } from "../lib/transaction-aware-execute" -import { DatabaseLive } from "./database" export class ChannelAccessSyncService extends Context.Service()( "ChannelAccessSyncService", @@ -395,5 +394,5 @@ export class ChannelAccessSyncService extends Context.Service - yield* start - - return { - start: Effect.void, - } + return { run: Option.some(run) } }), }) { static readonly layer = Layer.effect(this, this.make).pipe( @@ -743,3 +741,16 @@ export class DiscordGatewayService extends Context.Service()( @@ -324,7 +323,6 @@ export class ConnectConversationService extends Context.Service( Effect.scoped( make.pipe( Effect.provide(Layer.effect(MessageOutboxDispatcher, MessageOutboxDispatcher.make)), + Effect.provide(Layer.effect(MessageOutboxProcessor, MessageOutboxProcessor.make)), Effect.provide(Layer.succeed(MessageSideEffectService, sideEffects)), Effect.provide(MessageOutboxRepo.layer), Effect.provide( diff --git a/apps/backend/src/services/message-outbox-dispatcher.ts b/apps/backend/src/services/message-outbox-dispatcher.ts index 53cb99cb2..0afe4eae2 100644 --- a/apps/backend/src/services/message-outbox-dispatcher.ts +++ b/apps/backend/src/services/message-outbox-dispatcher.ts @@ -1,39 +1,21 @@ import { Pool, type PoolClient } from "pg" -import { - MessageCreatedPayloadSchema, - MessageDeletedPayloadSchema, - type MessageOutboxEventRecord, - MessageOutboxRepo, - MessageUpdatedPayloadSchema, - ReactionCreatedPayloadSchema, - ReactionDeletedPayloadSchema, -} from "@hazel/backend-core/repositories" import { Database } from "@hazel/db" -import { Context, Effect, Layer, Redacted, Schema } from "effect" +import { Context, Effect, Layer, Redacted } from "effect" import { EnvVars } from "../lib/env-vars" -import { formatError } from "../lib/format-error" -import { DatabaseLive } from "./database" -import { MessageSideEffectService } from "./message-side-effect-service" +import { MessageOutboxProcessor } from "./message-outbox-processor" -const OUTBOX_BATCH_SIZE = 100 const OUTBOX_POLL_MIN_MS = 250 const OUTBOX_POLL_MAX_MS = 2_000 const OUTBOX_LOCK_RETRY_INTERVAL = "5 seconds" -const OUTBOX_LOCK_TIMEOUT_MS = 2 * 60 * 1000 -const OUTBOX_FAILURE_LIMIT = 25 const OUTBOX_DISPATCHER_LOCK_KEY = 1_046_277_921 -const computeRetryDelayMs = (attempt: number): number => - Math.min(5_000 * 3 ** Math.max(0, attempt - 1), 300_000) - export class MessageOutboxDispatcher extends Context.Service()( "MessageOutboxDispatcher", { make: Effect.gen(function* () { const envVars = yield* EnvVars const database = yield* Database.Database - const outboxRepo = yield* MessageOutboxRepo - const sideEffects = yield* MessageSideEffectService + const processor = yield* MessageOutboxProcessor const workerId = `backend-outbox-${crypto.randomUUID()}` const pool = yield* Effect.acquireRelease( @@ -61,96 +43,7 @@ export class MessageOutboxDispatcher extends Context.Service reserved.release()) }) - const processEvent = Effect.fn("MessageOutboxDispatcher.processEvent")(function* ( - event: MessageOutboxEventRecord, - ) { - const dedupeKey = `hazel:outbox:${event.eventType}:${event.aggregateId}:${event.sequence}` - - switch (event.eventType) { - case "message_created": - yield* sideEffects.handleMessageCreated( - Schema.decodeUnknownSync(MessageCreatedPayloadSchema)(event.payload), - dedupeKey, - ) - break - case "message_updated": - yield* sideEffects.handleMessageUpdated( - Schema.decodeUnknownSync(MessageUpdatedPayloadSchema)(event.payload), - dedupeKey, - ) - break - case "message_deleted": - yield* sideEffects.handleMessageDeleted( - Schema.decodeUnknownSync(MessageDeletedPayloadSchema)(event.payload), - dedupeKey, - ) - break - case "reaction_created": - yield* sideEffects.handleReactionCreated( - Schema.decodeUnknownSync(ReactionCreatedPayloadSchema)(event.payload), - dedupeKey, - ) - break - case "reaction_deleted": - yield* sideEffects.handleReactionDeleted( - Schema.decodeUnknownSync(ReactionDeletedPayloadSchema)(event.payload), - dedupeKey, - ) - break - } - }) - - const processBatch = Effect.fnUntraced(function* () { - const batch = yield* outboxRepo.claimNextBatch({ - limit: OUTBOX_BATCH_SIZE, - workerId, - lockTimeoutMs: OUTBOX_LOCK_TIMEOUT_MS, - }) - - if (batch.length === 0) { - return { isEmpty: true } as const - } - - yield* Effect.gen(function* () { - for (const event of batch) { - const result = yield* processEvent(event).pipe(Effect.result) - if (result._tag === "Success") { - yield* outboxRepo.markProcessed(event.id) - continue - } - - const nextAttempt = event.attemptCount + 1 - const errorMessage = formatError(result.failure) - - yield* Effect.logWarning("Outbox event processing failed", { - eventId: event.id, - eventType: event.eventType, - sequence: event.sequence, - attempt: nextAttempt, - willRetry: nextAttempt < OUTBOX_FAILURE_LIMIT, - error: errorMessage, - }) - - if (nextAttempt >= OUTBOX_FAILURE_LIMIT) { - yield* outboxRepo.markFailed(event.id, { - lastError: errorMessage, - }) - continue - } - - yield* outboxRepo.markRetry(event.id, { - availableAt: new Date(Date.now() + computeRetryDelayMs(nextAttempt)), - lastError: errorMessage, - }) - } - }).pipe( - Effect.withSpan("MessageOutboxDispatcher.processBatch", { - attributes: { "batch.size": batch.length }, - }), - ) - - return { isEmpty: false } as const - }) + const processBatch = () => processor.processBatch(workerId) const runLeaderLoop = Effect.gen(function* () { let pollDelayMs = OUTBOX_POLL_MIN_MS @@ -247,9 +140,7 @@ export class MessageOutboxDispatcher extends Context.Service + Math.min(5_000 * 3 ** Math.max(0, attempt - 1), 300_000) + +export interface OutboxBatchResult { + readonly isEmpty: boolean + /** Events whose processing failed and were rescheduled for a later attempt. */ + readonly retried: number +} + +/** + * Claims and processes one batch of message outbox events at a time. Claims use + * `FOR UPDATE SKIP LOCKED` with a lock timeout, so a crashed worker's events are reclaimed. + * Callers serialize batches (the Bun leader loop, or the dispatcher Durable Object) to keep + * per-aggregate ordering. + */ +export class MessageOutboxProcessor extends Context.Service()( + "MessageOutboxProcessor", + { + make: Effect.gen(function* () { + const outboxRepo = yield* MessageOutboxRepo + const sideEffects = yield* MessageSideEffectService + + const processEvent = Effect.fn("MessageOutboxDispatcher.processEvent")(function* ( + event: MessageOutboxEventRecord, + ) { + const dedupeKey = `hazel:outbox:${event.eventType}:${event.aggregateId}:${event.sequence}` + + switch (event.eventType) { + case "message_created": + yield* sideEffects.handleMessageCreated( + Schema.decodeUnknownSync(MessageCreatedPayloadSchema)(event.payload), + dedupeKey, + ) + break + case "message_updated": + yield* sideEffects.handleMessageUpdated( + Schema.decodeUnknownSync(MessageUpdatedPayloadSchema)(event.payload), + dedupeKey, + ) + break + case "message_deleted": + yield* sideEffects.handleMessageDeleted( + Schema.decodeUnknownSync(MessageDeletedPayloadSchema)(event.payload), + dedupeKey, + ) + break + case "reaction_created": + yield* sideEffects.handleReactionCreated( + Schema.decodeUnknownSync(ReactionCreatedPayloadSchema)(event.payload), + dedupeKey, + ) + break + case "reaction_deleted": + yield* sideEffects.handleReactionDeleted( + Schema.decodeUnknownSync(ReactionDeletedPayloadSchema)(event.payload), + dedupeKey, + ) + break + } + }) + + const processBatch = Effect.fnUntraced(function* (workerId: string) { + const batch = yield* outboxRepo.claimNextBatch({ + limit: OUTBOX_BATCH_SIZE, + workerId, + lockTimeoutMs: OUTBOX_LOCK_TIMEOUT_MS, + }) + + if (batch.length === 0) { + return { isEmpty: true, retried: 0 } satisfies OutboxBatchResult + } + + let retried = 0 + yield* Effect.gen(function* () { + for (const event of batch) { + const result = yield* processEvent(event).pipe(Effect.result) + if (result._tag === "Success") { + yield* outboxRepo.markProcessed(event.id) + continue + } + + const nextAttempt = event.attemptCount + 1 + const errorMessage = formatError(result.failure) + + yield* Effect.logWarning("Outbox event processing failed", { + eventId: event.id, + eventType: event.eventType, + sequence: event.sequence, + attempt: nextAttempt, + willRetry: nextAttempt < OUTBOX_FAILURE_LIMIT, + error: errorMessage, + }) + + if (nextAttempt >= OUTBOX_FAILURE_LIMIT) { + yield* outboxRepo.markFailed(event.id, { + lastError: errorMessage, + }) + continue + } + + retried++ + yield* outboxRepo.markRetry(event.id, { + availableAt: new Date(Date.now() + computeRetryDelayMs(nextAttempt)), + lastError: errorMessage, + }) + } + }).pipe( + Effect.withSpan("MessageOutboxDispatcher.processBatch", { + attributes: { "batch.size": batch.length }, + }), + ) + + return { isEmpty: false, retried } satisfies OutboxBatchResult + }) + + return { processBatch } as const + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(MessageOutboxRepo.layer), + Layer.provide(MessageSideEffectService.layer), + ) +} diff --git a/apps/backend/src/services/message-side-effect-service.ts b/apps/backend/src/services/message-side-effect-service.ts index aacd2bac6..b9c37eec7 100644 --- a/apps/backend/src/services/message-side-effect-service.ts +++ b/apps/backend/src/services/message-side-effect-service.ts @@ -1,6 +1,5 @@ -import { HttpApiClient } from "effect/http-api" import { and, Database, eq, isNull, schema, sql } from "@hazel/db" -import { Cluster, WorkflowInitializationError } from "@hazel/domain" +import { WorkflowInitializationError } from "@hazel/domain" import { Context, Array, Config, Effect, Layer, Option } from "effect" import type { MessageCreatedPayload, @@ -11,6 +10,7 @@ import type { } from "@hazel/backend-core" import { formatError } from "../lib/format-error" import { DiscordSyncWorker, DiscordSyncWorkerLayer } from "./chat-sync/discord-sync-worker" +import { makeClusterClient } from "../lib/cluster-client" export class MessageSideEffectService extends Context.Service()( "MessageSideEffectService", @@ -19,9 +19,7 @@ export class MessageSideEffectService extends Context.Service()("Moc } }), }) { - static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(DatabaseLive)) + static readonly layer = Layer.effect(this, this.make) } diff --git a/apps/backend/src/services/oauth-bearer-auth.ts b/apps/backend/src/services/oauth-bearer-auth.ts index 5cbfa3a44..52a493b95 100644 --- a/apps/backend/src/services/oauth-bearer-auth.ts +++ b/apps/backend/src/services/oauth-bearer-auth.ts @@ -17,105 +17,98 @@ import { Context, Effect, Layer, Option, Schema } from "effect" * access tokens are a different token class (issued via /oauth/token) and need * the IdP introspection path. */ -export class OAuthBearerAuth extends Context.Service()( - "@hazel/backend/OAuthBearerAuth", - { - make: Effect.gen(function* () { - const clerk = yield* ClerkClient - const userRepo = yield* UserRepo +export class OAuthBearerAuth extends Context.Service()("@hazel/backend/OAuthBearerAuth", { + make: Effect.gen(function* () { + const clerk = yield* ClerkClient + const userRepo = yield* UserRepo - const decodeClerkUserId = Schema.decodeUnknownEffect(ClerkUserId) + const decodeClerkUserId = Schema.decodeUnknownEffect(ClerkUserId) - const authenticate = (accessToken: string) => - Effect.gen(function* () { - const introspected = yield* Effect.tryPromise({ - try: () => clerk.raw.idPOAuthAccessToken.verify(accessToken), - catch: (error) => - new UnauthorizedError({ - message: "Invalid OAuth access token", - detail: String(error), - }), - }) + const authenticate = (accessToken: string) => + Effect.gen(function* () { + const introspected = yield* Effect.tryPromise({ + try: () => clerk.raw.idPOAuthAccessToken.verify(accessToken), + catch: (error) => + new UnauthorizedError({ + message: "Invalid OAuth access token", + detail: String(error), + }), + }) + + if (introspected.revoked) { + return yield* Effect.fail( + new UnauthorizedError({ + message: "OAuth access token has been revoked", + detail: introspected.revocationReason ?? "revoked", + }), + ) + } - if (introspected.revoked) { - return yield* Effect.fail( + if (introspected.expired) { + return yield* Effect.fail( + new UnauthorizedError({ + message: "OAuth access token has expired", + detail: "expired", + }), + ) + } + + const clerkUserId = yield* decodeClerkUserId(introspected.subject).pipe( + Effect.mapError( + () => new UnauthorizedError({ - message: "OAuth access token has been revoked", - detail: introspected.revocationReason ?? "revoked", + message: "OAuth access token has an invalid subject", + detail: introspected.subject, }), - ) - } + ), + ) - if (introspected.expired) { - return yield* Effect.fail( + const userOption = yield* userRepo.findByExternalId(clerkUserId).pipe( + Effect.catchTag("DatabaseError", (err) => + Effect.fail( new UnauthorizedError({ - message: "OAuth access token has expired", - detail: "expired", + message: "Failed to resolve OAuth user", + detail: String(err), }), - ) - } - - const clerkUserId = yield* decodeClerkUserId(introspected.subject).pipe( - Effect.mapError( - () => - new UnauthorizedError({ - message: "OAuth access token has an invalid subject", - detail: introspected.subject, - }), ), - ) + ), + ) - const userOption = yield* userRepo.findByExternalId(clerkUserId).pipe( - Effect.catchTag( - "DatabaseError", - (err) => - Effect.fail( - new UnauthorizedError({ - message: "Failed to resolve OAuth user", - detail: String(err), - }), - ), + const user = yield* Option.match(userOption, { + onSome: (u) => Effect.succeed(u), + onNone: () => + Effect.fail( + new UnauthorizedError({ + message: + "OAuth user has no Hazel account. Sign in to Hazel at least once to provision an account, then retry.", + detail: clerkUserId, + }), ), - ) - - const user = yield* Option.match(userOption, { - onSome: (u) => Effect.succeed(u), - onNone: () => - Effect.fail( - new UnauthorizedError({ - message: - "OAuth user has no Hazel account. Sign in to Hazel at least once to provision an account, then retry.", - detail: clerkUserId, - }), - ), - }) + }) - return { - currentUser: new CurrentUser.Schema({ - id: user.id, - role: "member", - organizationId: undefined, - avatarUrl: user.avatarUrl ?? undefined, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - isOnboarded: user.isOnboarded, - timezone: user.timezone, - settings: user.settings, - }), - scopes: introspected.scopes, - clientId: introspected.clientId, - } - }).pipe( - Effect.withSpan("OAuthBearerAuth.authenticate"), - ) + return { + currentUser: new CurrentUser.Schema({ + id: user.id, + role: "member", + organizationId: undefined, + avatarUrl: user.avatarUrl ?? undefined, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + isOnboarded: user.isOnboarded, + timezone: user.timezone, + settings: user.settings, + }), + scopes: introspected.scopes, + clientId: introspected.clientId, + } + }).pipe(Effect.withSpan("OAuthBearerAuth.authenticate")) - return { - authenticate, - } as const - }), - }, -) { + return { + authenticate, + } as const + }), +}) { static readonly layer = Layer.effect(this, this.make).pipe( Layer.provide(ClerkClient.layer), Layer.provide(UserRepo.layer), diff --git a/apps/backend/src/services/object-storage.ts b/apps/backend/src/services/object-storage.ts new file mode 100644 index 000000000..152ef229a --- /dev/null +++ b/apps/backend/src/services/object-storage.ts @@ -0,0 +1,57 @@ +import { AwsClient } from "aws4fetch" +import { Config, Context, Effect, Layer, Redacted, Schema } from "effect" + +export class ObjectStorageError extends Schema.TaggedError()("ObjectStorageError", { + message: Schema.String, + cause: Schema.optional(Schema.Unknown), +}) {} + +export interface PresignPutOptions { + readonly contentType: string + /** Seconds until the URL expires. */ + readonly expiresIn: number +} + +/** + * Presigned uploads against the S3 API of the uploads bucket (R2 in every deployed stage, MinIO + * locally). SigV4 via `aws4fetch` runs on Workers and Bun alike, unlike Bun's built-in `s3`. + * Path-style URLs: `${S3_ENDPOINT}/${S3_BUCKET}/${key}`. + */ +export class ObjectStorage extends Context.Service()("ObjectStorage", { + make: Effect.gen(function* () { + const endpoint = (yield* Config.String("S3_ENDPOINT")).replace(/\/+$/, "") + const bucket = yield* Config.String("S3_BUCKET") + const region = yield* Config.String("S3_REGION").pipe(Config.withDefault("auto")) + const accessKeyId = yield* Config.Redacted("S3_ACCESS_KEY_ID") + const secretAccessKey = yield* Config.Redacted("S3_SECRET_ACCESS_KEY") + + const client = new AwsClient({ + accessKeyId: Redacted.value(accessKeyId), + secretAccessKey: Redacted.value(secretAccessKey), + service: "s3", + region, + }) + + const presignPut = Effect.fn("ObjectStorage.presignPut")(function* ( + key: string, + options: PresignPutOptions, + ) { + const url = new URL(`${endpoint}/${bucket}/${key.split("/").map(encodeURIComponent).join("/")}`) + url.searchParams.set("X-Amz-Expires", String(options.expiresIn)) + const signed = yield* Effect.tryPromise({ + try: () => + client.sign(url.toString(), { + method: "PUT", + headers: { "Content-Type": options.contentType }, + aws: { signQuery: true }, + }), + catch: (cause) => new ObjectStorageError({ message: "Failed to presign upload URL", cause }), + }) + return signed.url + }) + + return { presignPut } as const + }), +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/apps/backend/src/services/rate-limiter-redis.ts b/apps/backend/src/services/rate-limiter-redis.ts new file mode 100644 index 000000000..1ab453c13 --- /dev/null +++ b/apps/backend/src/services/rate-limiter-redis.ts @@ -0,0 +1,81 @@ +import { Redis, type RedisErrors } from "@hazel/effect-bun" +import { Effect, Layer } from "effect" +import { RateLimiter, RateLimiterError } from "./rate-limiter" + +/** + * Fixed-window rate limiting Lua script. + * + * This script atomically: + * 1. Gets the current count for the key + * 2. If no key exists, creates it with count=1 and TTL=windowMs + * 3. If key exists and count < limit, increments and returns allowed + * 4. If key exists and count >= limit, returns denied with TTL info + * + * Returns: [allowed (0/1), remaining, resetAfterMs] + */ +const FIXED_WINDOW_SCRIPT = ` +local key = KEYS[1] +local limit = tonumber(ARGV[1]) +local windowMs = tonumber(ARGV[2]) + +local current = tonumber(redis.call("GET", key) or "0") +local ttl = tonumber(redis.call("PTTL", key)) + +if ttl < 0 then + ttl = windowMs +end + +if current < limit then + if current == 0 then + redis.call("SET", key, 1, "PX", windowMs) + else + redis.call("INCR", key) + end + return {1, limit - current - 1, ttl} +else + return {0, 0, ttl} +end +` + +/** `RateLimiter` backed by Redis (the Bun entry). */ +export const RateLimiterRedisLive = Layer.effect( + RateLimiter, + Effect.gen(function* () { + const redis = yield* Redis + + return RateLimiter.of({ + /** + * Check and consume from a rate limit bucket using fixed-window algorithm. + * + * @param key - Unique key for this rate limit (e.g., "messages:user-123") + * @param limit - Maximum requests allowed per window + * @param windowMs - Window duration in milliseconds + * @returns RateLimitResult with allowed status and metadata + */ + consume: (key: string, limit: number, windowMs: number) => + redis + .send<[number, number, number]>("EVAL", [ + FIXED_WINDOW_SCRIPT, + "1", + `ratelimit:${key}`, + String(limit), + String(windowMs), + ]) + .pipe( + Effect.map(([allowed, remaining, resetAfterMs]) => ({ + allowed: allowed === 1, + remaining, + resetAfterMs, + limit, + })), + Effect.mapError( + (e: RedisErrors) => + new RateLimiterError({ + message: "Failed to execute rate limit check", + cause: e, + }), + ), + ), + }) + }), +).pipe(Layer.provide(Redis.Default)) diff --git a/apps/backend/src/services/rate-limiter.ts b/apps/backend/src/services/rate-limiter.ts index 176134548..f21c34a7a 100644 --- a/apps/backend/src/services/rate-limiter.ts +++ b/apps/backend/src/services/rate-limiter.ts @@ -1,4 +1,3 @@ -import { Redis, type RedisErrors } from "@hazel/effect-bun" import { Context, Effect, Layer, Schema } from "effect" /** @@ -21,85 +20,27 @@ export class RateLimiterError extends Schema.TaggedError()("Ra }) {} /** - * Fixed-window rate limiting Lua script. - * - * This script atomically: - * 1. Gets the current count for the key - * 2. If no key exists, creates it with count=1 and TTL=windowMs - * 3. If key exists and count < limit, increments and returns allowed - * 4. If key exists and count >= limit, returns denied with TTL info - * - * Returns: [allowed (0/1), remaining, resetAfterMs] + * Fixed-window rate limiting. Implementations: Redis on Bun (`rate-limiter-redis.ts`), a Durable + * Object per key on Cloudflare (`worker/rate-limiter-object.ts`), memory in tests. */ -const FIXED_WINDOW_SCRIPT = ` -local key = KEYS[1] -local limit = tonumber(ARGV[1]) -local windowMs = tonumber(ARGV[2]) - -local current = tonumber(redis.call("GET", key) or "0") -local ttl = tonumber(redis.call("PTTL", key)) - -if ttl < 0 then - ttl = windowMs -end - -if current < limit then - if current == 0 then - redis.call("SET", key, 1, "PX", windowMs) - else - redis.call("INCR", key) - end - return {1, limit - current - 1, ttl} -else - return {0, 0, ttl} -end -` - -/** - * Rate limiter service backed by Redis via @hazel/effect-bun - */ -export class RateLimiter extends Context.Service()("RateLimiter", { - make: Effect.gen(function* () { - const redis = yield* Redis - - return { - /** - * Check and consume from a rate limit bucket using fixed-window algorithm. - * - * @param key - Unique key for this rate limit (e.g., "messages:user-123") - * @param limit - Maximum requests allowed per window - * @param windowMs - Window duration in milliseconds - * @returns RateLimitResult with allowed status and metadata - */ - consume: (key: string, limit: number, windowMs: number) => - redis - .send<[number, number, number]>("EVAL", [ - FIXED_WINDOW_SCRIPT, - "1", - `ratelimit:${key}`, - String(limit), - String(windowMs), - ]) - .pipe( - Effect.map(([allowed, remaining, resetAfterMs]) => ({ - allowed: allowed === 1, - remaining, - resetAfterMs, - limit, - })), - Effect.mapError( - (e: RedisErrors) => - new RateLimiterError({ - message: "Failed to execute rate limit check", - cause: e, - }), - ), - ), - } - }), -}) { - static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(Redis.Default)) -} +export class RateLimiter extends Context.Service< + RateLimiter, + { + /** + * Check and consume from a rate limit bucket using fixed-window algorithm. + * + * @param key - Unique key for this rate limit (e.g., "messages:user-123") + * @param limit - Maximum requests allowed per window + * @param windowMs - Window duration in milliseconds + * @returns RateLimitResult with allowed status and metadata + */ + readonly consume: ( + key: string, + limit: number, + windowMs: number, + ) => Effect.Effect + } +>()("RateLimiter") {} /** * In-memory rate limiter for testing (no Redis required) diff --git a/apps/backend/src/worker.ts b/apps/backend/src/worker.ts new file mode 100644 index 000000000..68925e239 --- /dev/null +++ b/apps/backend/src/worker.ts @@ -0,0 +1,102 @@ +/** + * The api Worker (alchemy two-step form): the backend's HTTP API and RPC, plus the Durable + * Objects that replace the Bun process's background loops and Redis. The Bun entry is `index.ts`. + * Plan: infra/cloudflare-migration-plan.md, Phase 4. + */ +import { bindBotGateways } from "@hazel/bot-gateway/object" +import { HazelStack, hazelWorkerProps, stageProps } from "@hazel/infra/cloudflare" +import { cachedRecoverable } from "@hazel/infra/cached-recoverable" +import { isolateContext } from "@hazel/infra/worker-http" +import * as Cloudflare from "alchemy/Cloudflare" +import { Effect, Layer } from "effect" +import { + DISCORD_GATEWAY_NAME, + DiscordGatewayObject, + DiscordGatewayObjectLive, +} from "./worker/discord-gateway-object" +import { apiEnv } from "./worker/env" +import { buildApp, makeFetch } from "./worker/http" +import { + OUTBOX_DISPATCHER_NAME, + OutboxDispatcherObject, + OutboxDispatcherObjectLive, +} from "./worker/outbox-dispatcher-object" +import { CACHE_BINDING } from "./worker/platform" +import { RateLimiterObject, RateLimiterObjectLive } from "./worker/rate-limiter-object" + +/** Session and user-lookup caches (Redis on Bun). */ +export const ApiCache = Cloudflare.KV.Namespace( + "api-cache", + stageProps("api-cache", (title) => ({ title })), +) + +/** `__ALCHEMY_RUNTIME__` folds to `true` in the bundle, so the stack-side branch is tree-shaken. */ +const props = Effect.gen(function* () { + if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } + const stack = yield* HazelStack + const cache = yield* ApiCache + return { + main: import.meta.url, + ...hazelWorkerProps("api", stack), + workersDev: stack.stage.kind !== "prd", + domain: stack.domains.api, + observability: { + enabled: true, + logs: { enabled: true, invocationLogs: true, destinations: ["maple-logs"] }, + traces: { enabled: true, destinations: ["maple-traces"] }, + }, + env: { + HAZEL_DB: stack.db.hyperdrive, + [CACHE_BINDING]: cache, + ...(yield* apiEnv(stack)), + }, + } +}) + +export class Api extends Cloudflare.Worker< + Api, + Cloudflare.WorkerShape, + RateLimiterObject | OutboxDispatcherObject | DiscordGatewayObject +>()("api") {} + +export default Api.make( + props, + Effect.gen(function* () { + // Yielding a hosted class binds, registers and exports it. + const rateLimiters = yield* RateLimiterObject + const outbox = yield* OutboxDispatcherObject + const discordGateway = yield* DiscordGatewayObject + // Hosted by the bot-gateway Worker; BotGatewayService publishes bot events into it. + const botGateways = yield* bindBotGateways + const env = yield* Cloudflare.WorkerEnvironment + const exec = yield* Cloudflare.WorkerExecutionContext + + // The graph builds on the first request, not here: init also runs at plan time, where + // alchemy would auto-bind every `Config` read. + const isolate = isolateContext(yield* Effect.context()) + const app = yield* cachedRecoverable(buildApp(isolate, env, rateLimiters, botGateways)) + + // Backstops for the Durable Objects: drain anything a missed kick left behind, and keep + // the Discord gateway session up across deploys and evictions. + yield* Cloudflare.Workers.cron("* * * * *", () => + Effect.all( + [ + outbox.getByName(OUTBOX_DISPATCHER_NAME).kick(), + discordGateway.getByName(DISCORD_GATEWAY_NAME).ensureRunning(), + ], + { concurrency: "unbounded", discard: true }, + ), + ) + + return { fetch: makeFetch(app, env, exec, outbox) } + }).pipe( + Effect.provide( + Layer.mergeAll( + RateLimiterObjectLive, + OutboxDispatcherObjectLive, + DiscordGatewayObjectLive, + Cloudflare.Workers.CronEventSourceLive, + ), + ), + ), +) diff --git a/apps/backend/src/worker/discord-gateway-object.ts b/apps/backend/src/worker/discord-gateway-object.ts new file mode 100644 index 000000000..93e7b4734 --- /dev/null +++ b/apps/backend/src/worker/discord-gateway-object.ts @@ -0,0 +1,73 @@ +/** + * The Discord gateway session on Cloudflare: a singleton Durable Object holding the outbound + * gateway WebSocket that the Bun process held in a forked fiber. + * + * `ensureRunning()` starts the session if this instance isn't running one. The api Worker calls it + * from a per-minute cron, which also restarts the session after a deploy or an eviction; while a + * session runs, an alarm every 30s keeps the object warm and restarts a session that ended. + */ +import { bindBotGateways } from "@hazel/bot-gateway/object" +import * as Cloudflare from "alchemy/Cloudflare" +import { RuntimeContext } from "alchemy/RuntimeContext" +import { Context, Effect, Layer, Option, Ref } from "effect" +import { FetchHttpClient } from "effect/http" +import * as Socket from "effect/socket/Socket" +import { AppServicesLive } from "../app" +import { DiscordGatewayService } from "../services/chat-sync/discord-gateway-service" +import { objectPlatformLive } from "./platform" + +export interface DiscordGatewayShape { + /** Start the gateway session unless one is running; reports whether one runs afterwards. */ + readonly ensureRunning: () => Effect.Effect<{ readonly running: boolean }> +} + +export class DiscordGatewayObject extends Cloudflare.DurableObject< + DiscordGatewayObject, + DiscordGatewayShape +>()("DiscordGateway") {} + +/** The one gateway instance: Discord allows a single session per bot token and shard. */ +export const DISCORD_GATEWAY_NAME = "discord" + +const WATCHDOG_INTERVAL_MS = 30_000 + +export const DiscordGatewayObjectLive = DiscordGatewayObject.make( + Effect.gen(function* () { + const state = yield* Cloudflare.DurableObjectState + const env = yield* Cloudflare.WorkerEnvironment + // Cross-script binding to the bot gateway's Durable Objects (BotGatewayService publishes there). + const botGateways = yield* bindBotGateways + return Effect.gen(function* () { + const instanceScope = yield* Effect.scope + const services = yield* Layer.build( + // The cast restores the layer's type: the circular `DiscordSyncWorker` typing collapses + // `AppServicesLive`'s error and requirement channels to `unknown` (see index.ts). + DiscordGatewayService.layer.pipe( + Layer.provide(AppServicesLive), + Layer.provideMerge(FetchHttpClient.layer), + Layer.provide(objectPlatformLive(env, botGateways)), + ) as unknown as Layer.Layer, + ) + const gateway = Context.get(services, DiscordGatewayService) + const running = yield* Ref.make(false) + + const ensureRunning = Effect.gen(function* () { + if (Option.isNone(gateway.run)) return { running: false } + if (!(yield* Ref.getAndSet(running, true))) { + yield* gateway.run.value.pipe( + Effect.provide(Socket.layerWebSocketConstructorGlobal), + Effect.ensuring(Ref.set(running, false)), + Effect.forkIn(instanceScope), + ) + } + yield* state.storage.setAlarm(Date.now() + WATCHDOG_INTERVAL_MS) + return { running: true } + }).pipe(Effect.provide(RuntimeContext.phantom)) + + return { + ensureRunning: () => ensureRunning, + alarm: () => Effect.asVoid(ensureRunning), + } + }) + }), +) diff --git a/apps/backend/src/worker/env.ts b/apps/backend/src/worker/env.ts new file mode 100644 index 000000000..6141e9c9d --- /dev/null +++ b/apps/backend/src/worker/env.ts @@ -0,0 +1,65 @@ +/** + * The api Worker's deploy-time env: every key the backend reads through `Config` (see + * `services/`, `@hazel/auth`, `@hazel/integrations`). Plain values become vars, `Redacted` ones + * Worker secrets. Read from the Worker's props only, never from its init. + */ +import type { HazelStackContext } from "@hazel/infra/cloudflare" +import { + derived, + merge, + optionalPlain, + optionalSecret, + plainWithDefault, + requirePlainEntry, + requireSecretEntry, + telemetryEnv, +} from "@hazel/infra/env" + +export const apiEnv = ({ stage, urls }: HazelStackContext) => + merge( + telemetryEnv(stage), + derived("IS_DEV", stage.kind === "dev" ? "true" : "false"), + derived("API_BASE_URL", urls.api), + derived("FRONTEND_URL", urls.web), + optionalPlain("COOKIE_DOMAIN"), + + // Auth (Clerk) + requireSecretEntry("CLERK_SECRET_KEY"), + requirePlainEntry("CLERK_PUBLISHABLE_KEY"), + optionalSecret("CLERK_WEBHOOK_SECRET"), + + // Uploads: presigned against the bucket's S3 API (R2) + requirePlainEntry("S3_BUCKET"), + requirePlainEntry("S3_ENDPOINT"), + optionalPlain("S3_REGION"), + optionalPlain("S3_PUBLIC_URL"), + requireSecretEntry("S3_ACCESS_KEY_ID"), + requireSecretEntry("S3_SECRET_ACCESS_KEY"), + + // Workflows run on the Railway-hosted cluster (infra/cloudflare-migration-plan.md, Phase 5) + requirePlainEntry("CLUSTER_URL"), + // Sent by `makeClusterClient`; the cluster rejects workflow calls without it once set. + optionalSecret("CLUSTER_API_SECRET"), + + // Integrations + requireSecretEntry("INTEGRATION_ENCRYPTION_KEY"), + optionalPlain("INTEGRATION_ENCRYPTION_KEY_VERSION"), + optionalSecret("INTEGRATION_ENCRYPTION_KEY_PREV"), + optionalPlain("INTEGRATION_ENCRYPTION_KEY_VERSION_PREV"), + optionalPlain("LINEAR_CLIENT_ID"), + optionalSecret("LINEAR_CLIENT_SECRET"), + optionalPlain("DISCORD_CLIENT_ID"), + optionalSecret("DISCORD_CLIENT_SECRET"), + optionalSecret("DISCORD_BOT_TOKEN"), + // Off until cutover: the Railway backend still holds the gateway session, and Discord + // allows one per bot token. + plainWithDefault("DISCORD_GATEWAY_ENABLED", "false"), + optionalPlain("DISCORD_GATEWAY_INTENTS"), + optionalPlain("GITHUB_APP_ID"), + optionalPlain("GITHUB_APP_SLUG"), + optionalSecret("GITHUB_APP_PRIVATE_KEY"), + optionalSecret("GITHUB_WEBHOOK_SECRET"), + optionalPlain("GITHUB_WEBHOOK_SKIP_SIGNATURE"), + optionalSecret("INTERNAL_SECRET"), + optionalSecret("KLIPY_API_KEY"), + ) diff --git a/apps/backend/src/worker/http.ts b/apps/backend/src/worker/http.ts new file mode 100644 index 000000000..bbd7378b3 --- /dev/null +++ b/apps/backend/src/worker/http.ts @@ -0,0 +1,77 @@ +/** + * The api Worker's request path: the route graph built once per isolate on the first request, + * and the fetch handler that runs it under a per-request Postgres connection. + */ +import { OutboxWrites } from "@hazel/backend-core" +import { bridgeHandler, forIsolate, WorkerPlatformLive } from "@hazel/infra/worker-http" +import type * as Cloudflare from "alchemy/Cloudflare" +import type { HttpEffect } from "alchemy/Http" +import { type Context, Effect, Layer } from "effect" +import { FetchHttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http" +import { AllRoutes, AppAuthorizationLive, AppServicesLive } from "../app" +import type { BotGatewayNamespace } from "../services/bot-gateway-transport" +import { OUTBOX_DISPATCHER_NAME, type OutboxDispatcherObject } from "./outbox-dispatcher-object" +import { provideRequestDatabase, requestPlatformLive } from "./platform" +import { type RateLimiterObject, rateLimiterLive } from "./rate-limiter-object" + +/** The route graph as one request handler, built under the isolate's context (never a request's). */ +export const buildApp = ( + isolate: Context.Context, + env: Record, + rateLimiters: Cloudflare.DurableObject, + botGateways: BotGatewayNamespace, +) => + forIsolate(isolate)( + HttpRouter.toHttpEffect( + // The cast restores the layer's type: the circular `DiscordSyncWorker` typing collapses + // `AppServicesLive`'s error and requirement channels to `unknown` (see index.ts). + AllRoutes.pipe( + Layer.provide(AppAuthorizationLive), + Layer.provide(AppServicesLive), + Layer.provideMerge(rateLimiterLive(rateLimiters)), + Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(WorkerPlatformLive), + Layer.provideMerge(requestPlatformLive(env, botGateways)), + ) as unknown as Layer.Layer, + ), + ).pipe(Effect.map(bridgeHandler)) + +const pathOf = (url: string): string => { + const query = url.indexOf("?") + return query === -1 ? url : url.slice(0, query) +} + +/** + * The fetch handler. `/health` answers before the route graph exists. Every other request runs + * the router with its own lazily-connecting Postgres client (closed with the request scope, which + * a streaming response carries until the stream ends), and wakes the outbox dispatcher when the + * request wrote outbox events. + */ +export const makeFetch = ( + app: Effect.Effect, + env: Record, + exec: Cloudflare.WorkerExecutionContext["Service"], + outbox: Cloudflare.DurableObject, +) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + if (request.method === "GET" && pathOf(request.url) === "/health") { + return HttpServerResponse.text("OK") + } + + const handler = yield* app.pipe(Effect.orDie) + let wroteOutbox = false + const response = yield* handler.pipe( + provideRequestDatabase(env), + Effect.provideService(OutboxWrites, { + mark: () => { + wroteOutbox = true + }, + }), + ) + if (wroteOutbox) { + // After the handler returned, so the write that marked it has committed. + yield* exec.waitUntil(outbox.getByName(OUTBOX_DISPATCHER_NAME).kick()) + } + return response + }) diff --git a/apps/backend/src/worker/outbox-dispatcher-object.ts b/apps/backend/src/worker/outbox-dispatcher-object.ts new file mode 100644 index 000000000..2cff49387 --- /dev/null +++ b/apps/backend/src/worker/outbox-dispatcher-object.ts @@ -0,0 +1,105 @@ +/** + * The message outbox dispatcher on Cloudflare: a singleton Durable Object replacing the Bun + * process's advisory-lock leader loop. Being single-threaded per name gives the same + * one-dispatcher-at-a-time guarantee, so per-aggregate ordering is preserved. + * + * Woken three ways: `kick()` after a request that wrote outbox events, an alarm while retries are + * pending, and the api Worker's per-minute cron as a backstop. + */ +import { bindBotGateways } from "@hazel/bot-gateway/object" +import * as Cloudflare from "alchemy/Cloudflare" +import { RuntimeContext } from "alchemy/RuntimeContext" +import { Context, Effect, Layer, Ref } from "effect" +import { FetchHttpClient } from "effect/http" +import { AppServicesLive } from "../app" +import { MessageOutboxProcessor, type OutboxBatchResult } from "../services/message-outbox-processor" +import { objectPlatformLive } from "./platform" + +export interface OutboxDispatcherShape { + /** Drain the outbox now (or right after the drain in progress). */ + readonly kick: () => Effect.Effect +} + +export class OutboxDispatcherObject extends Cloudflare.DurableObject< + OutboxDispatcherObject, + OutboxDispatcherShape +>()("OutboxDispatcher") {} + +/** The one dispatcher instance. */ +export const OUTBOX_DISPATCHER_NAME = "outbox" + +/** Batches per wake; the alarm continues a backlog larger than this. */ +const MAX_BATCHES_PER_DRAIN = 50 +/** Retries are rescheduled at least 5s out (`computeRetryDelayMs`); check back then. */ +const RETRY_RECHECK_MS = 5_000 + +export const OutboxDispatcherObjectLive = OutboxDispatcherObject.make( + Effect.gen(function* () { + const state = yield* Cloudflare.DurableObjectState + const env = yield* Cloudflare.WorkerEnvironment + // Cross-script binding to the bot gateway's Durable Objects (BotGatewayService publishes there). + const botGateways = yield* bindBotGateways + return Effect.gen(function* () { + const workerId = `cf-outbox-${crypto.randomUUID()}` + // Built in the instance scope, once per in-memory instance: its pooled client lives as + // long as the object. + const services = yield* Layer.build( + // The cast restores the layer's type: the circular `DiscordSyncWorker` typing collapses + // `AppServicesLive`'s error and requirement channels to `unknown` (see index.ts). + MessageOutboxProcessor.layer.pipe( + Layer.provide(AppServicesLive), + Layer.provideMerge(FetchHttpClient.layer), + Layer.provide(objectPlatformLive(env, botGateways)), + ) as unknown as Layer.Layer, + ) + const processor = Context.get(services, MessageOutboxProcessor) + + const draining = yield* Ref.make(false) + const rerun = yield* Ref.make(false) + + const drainOnce = Effect.gen(function* () { + let retried = 0 + for (let i = 0; i < MAX_BATCHES_PER_DRAIN; i++) { + // Requirements were satisfied by the build above; the circular typing leaves them `unknown`. + const result = yield* processor.processBatch(workerId) as Effect.Effect< + OutboxBatchResult, + unknown + > + retried += result.retried + if (result.isEmpty) return { backlog: false, retried } + } + return { backlog: true, retried } + }) + + const drain: Effect.Effect = Effect.gen(function* () { + if (yield* Ref.getAndSet(draining, true)) { + yield* Ref.set(rerun, true) + return + } + yield* Effect.gen(function* () { + let again = true + while (again) { + yield* Ref.set(rerun, false) + const { backlog, retried } = yield* drainOnce.pipe( + Effect.catchCause((cause) => + Effect.logError("Outbox drain failed", { + workerId, + cause: String(cause), + }).pipe(Effect.as({ backlog: false, retried: 1 })), + ), + ) + if (backlog || retried > 0) { + yield* state.storage.setAlarm(Date.now() + (backlog ? 0 : RETRY_RECHECK_MS)) + } + again = yield* Ref.get(rerun) + } + }).pipe(Effect.ensuring(Ref.set(draining, false))) + }).pipe(Effect.provide(RuntimeContext.phantom)) + + return { + kick: () => drain, + alarm: () => drain, + } + }) + }), +) diff --git a/apps/backend/src/worker/platform.ts b/apps/backend/src/worker/platform.ts new file mode 100644 index 000000000..51d44061e --- /dev/null +++ b/apps/backend/src/worker/platform.ts @@ -0,0 +1,68 @@ +/** + * The api Worker's platform services: what `index.ts` supplies on Bun (pooled Postgres, Redis + * caches, `ConfigProvider.fromEnv`), rebuilt over Cloudflare bindings. + */ +import type { KVNamespace } from "@cloudflare/workers-types" +import { Database } from "@hazel/db" +import { layerKvResultPersistence } from "@hazel/effect-cloudflare" +import { readHazelDbBinding } from "@hazel/infra/cloudflare" +import { workerEnvLayer } from "@hazel/infra/worker-runtime" +import { Effect, Layer, Option, Redacted } from "effect" +import { type BotGatewayNamespace, BotGatewayTransport } from "../services/bot-gateway-transport" + +/** KV namespace backing the session/user-lookup caches (Redis on Bun). */ +export const CACHE_BINDING = "CACHE" + +/** The Hyperdrive connection string, a defect when the binding is missing. */ +export const hazelDbConnectionString = (env: Record): string => + Option.match(readHazelDbBinding(env), { + onNone: () => { + throw new Error("HAZEL_DB Hyperdrive binding is missing from the Worker env") + }, + onSome: (binding) => binding.connectionString, + }) + +const cacheNamespace = (env: Record): KVNamespace => { + const kv = env[CACHE_BINDING] + if (kv === undefined) throw new Error(`${CACHE_BINDING} KV binding is missing from the Worker env`) + return kv as KVNamespace +} + +/** + * Platform services for an api request graph. `Database` reads the per-request + * `DatabaseConnection` the fetch handler provides (sockets are bound to the request that opened + * them). + */ +export const requestPlatformLive = (env: Record, botGateways: BotGatewayNamespace) => + Layer.mergeAll( + Database.layerRequestScoped, + BotGatewayTransport.layerDurableObject(botGateways), + layerKvResultPersistence(cacheNamespace(env)), + workerEnvLayer(env), + ) + +/** + * Platform services for a Durable Object. A Durable Object may keep connections across the + * calls it serves, so it holds one pooled client (through Hyperdrive) for its in-memory lifetime. + */ +export const objectPlatformLive = (env: Record, botGateways: BotGatewayNamespace) => + Layer.mergeAll( + BotGatewayTransport.layerDurableObject(botGateways), + Layer.unwrap( + Effect.sync(() => + Database.layer({ url: Redacted.make(hazelDbConnectionString(env)), ssl: false }), + ), + ), + layerKvResultPersistence(cacheNamespace(env)), + workerEnvLayer(env), + ) + +/** Opens the request's Postgres client; it connects lazily and is closed with the request scope. */ +export const provideRequestDatabase = (env: Record) => + Effect.provideServiceEffect( + Database.DatabaseConnection, + Effect.acquireRelease( + Effect.sync(() => Database.makeRequestConnection(hazelDbConnectionString(env))), + (connection) => Effect.promise(() => connection.end()).pipe(Effect.ignore), + ), + ) diff --git a/apps/backend/src/worker/rate-limiter-object.ts b/apps/backend/src/worker/rate-limiter-object.ts new file mode 100644 index 000000000..1182a38ab --- /dev/null +++ b/apps/backend/src/worker/rate-limiter-object.ts @@ -0,0 +1,75 @@ +/** + * Fixed-window rate limiting, one Durable Object per limit key: the same semantics as the Redis + * Lua script in `services/rate-limiter.ts`, with the object's single-threaded execution standing + * in for the script's atomicity. + */ +import * as Cloudflare from "alchemy/Cloudflare" +import { RuntimeContext } from "alchemy/RuntimeContext" +import { Effect, Layer } from "effect" +import { type RateLimitResult, RateLimiter } from "../services/rate-limiter" + +interface WindowState { + readonly startedAt: number + readonly count: number +} + +export interface RateLimiterShape { + readonly consume: (limit: number, windowMs: number) => Effect.Effect +} + +export class RateLimiterObject extends Cloudflare.DurableObject()( + "RateLimiter", +) {} + +const WINDOW_KEY = "window" + +export const RateLimiterObjectLive = RateLimiterObject.make( + Effect.gen(function* () { + const state = yield* Cloudflare.DurableObjectState + return Effect.gen(function* () { + // Cached across calls; storage keeps it across evictions. + let current = yield* state.storage.get(WINDOW_KEY) + + return { + consume: (limit: number, windowMs: number) => + Effect.gen(function* () { + const now = Date.now() + if (current === undefined || now - current.startedAt >= windowMs) { + current = { startedAt: now, count: 0 } + } + const resetAfterMs = Math.max(0, current.startedAt + windowMs - now) + if (current.count >= limit) { + return { + allowed: false, + remaining: 0, + resetAfterMs, + limit, + } satisfies RateLimitResult + } + current = { startedAt: current.startedAt, count: current.count + 1 } + yield* state.storage.put(WINDOW_KEY, current) + return { + allowed: true as boolean, + remaining: limit - current.count, + resetAfterMs, + limit, + } + }).pipe(Effect.provide(RuntimeContext.phantom)), + } + }) + }), +) + +/** The `RateLimiter` port over the Durable Object namespace the Worker bound. */ +export const rateLimiterLive = (limiters: Cloudflare.DurableObject) => + Layer.succeed( + RateLimiter, + RateLimiter.of({ + consume: (key, limit, windowMs) => + limiters + .getByName(key) + .consume(limit, windowMs) + // Discharge alchemy's phantom color: handlers run inside a Worker event. + .pipe(Effect.provide(RuntimeContext.phantom)), + }), + ) diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index c64bfe7f1..bff32c3a2 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -1,5 +1,7 @@ { "include": ["**/*.ts", "**/*.tsx"], + // Cloudflare Worker sources typecheck under workers-types: tsconfig.worker.json + "exclude": ["src/worker.ts", "src/worker/**", "node_modules"], "compilerOptions": { "target": "ES2022", "module": "ESNext", diff --git a/apps/backend/tsconfig.worker.json b/apps/backend/tsconfig.worker.json new file mode 100644 index 000000000..c21fcc30e --- /dev/null +++ b/apps/backend/tsconfig.worker.json @@ -0,0 +1,17 @@ +{ + "include": ["src/worker.ts", "src/worker/**/*.ts"], + "compilerOptions": { + "target": "esnext", + "module": "preserve", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + // No bun types: anything Bun-only reachable from the Worker graph fails here. + "types": ["node", "@cloudflare/workers-types"] + } +} diff --git a/apps/bot-gateway/alchemy.run.ts b/apps/bot-gateway/alchemy.run.ts new file mode 100644 index 000000000..4f7984115 --- /dev/null +++ b/apps/bot-gateway/alchemy.run.ts @@ -0,0 +1,8 @@ +/** The bot gateway Worker and its `BotGateway` Durable Object (`src/worker.ts`). */ +import { Effect } from "effect" +import BotGatewayWorkerLive, { BotGatewayWorker } from "./src/worker.ts" + +export { BotGatewayWorker } + +// oxlint-disable-next-line effecttsgo/strict-effect-provide +export default Effect.provide(BotGatewayWorker, BotGatewayWorkerLive) diff --git a/apps/bot-gateway/package.json b/apps/bot-gateway/package.json index 359640f88..41ab7f68a 100644 --- a/apps/bot-gateway/package.json +++ b/apps/bot-gateway/package.json @@ -3,9 +3,14 @@ "private": true, "type": "module", "module": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./object": "./src/object.ts" + }, "scripts": { "dev": "bun run --watch src/index.ts", "start": "bun run src/index.ts", + "test": "vitest run && bun test src/index.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -14,11 +19,15 @@ "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/effect-bun": "workspace:*", + "@hazel/infra": "workspace:*", "@hazel/schema": "workspace:*", + "alchemy": "catalog:alchemy", "effect": "catalog:effect" }, "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", "@types/bun": "1.3.9", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.0" } } diff --git a/apps/bot-gateway/src/gateway/auth.ts b/apps/bot-gateway/src/gateway/auth.ts new file mode 100644 index 000000000..274071f69 --- /dev/null +++ b/apps/bot-gateway/src/gateway/auth.ts @@ -0,0 +1,41 @@ +/** + * Bot token authentication for the gateway Worker: the same SHA-256 token-hash lookup the Bun + * gateway did, through Hyperdrive with a connection opened for this one lookup (a Worker's TCP + * socket belongs to the invocation that opened it). + */ +import { BotRepo } from "@hazel/backend-core/repositories" +import { Database } from "@hazel/db" +import { Effect, Layer, Option } from "effect" +import type { AuthResult } from "./relay.ts" + +export const hashBotToken = async (token: string): Promise => { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") +} + +/** Look a bot up by token; `connectionString` is the Hyperdrive binding's. */ +export const authenticateBotToken = (token: string, connectionString: string): Effect.Effect => + Effect.gen(function* () { + const tokenHash = yield* Effect.promise(() => hashBotToken(token)) + const repo = yield* BotRepo + const bot = yield* repo.findByTokenHash(tokenHash) + return Option.match(bot, { + onNone: (): AuthResult => ({ _tag: "Rejected", reason: "Invalid bot token" }), + onSome: (found): AuthResult => ({ _tag: "Authenticated", botId: found.id, botName: found.name }), + }) + }).pipe( + Effect.catchTag("DatabaseError", (error) => + Effect.succeed({ _tag: "Unavailable", reason: `bot lookup failed: ${error.type}` }), + ), + Effect.provide(BotRepo.layer.pipe(Layer.provide(Database.layerRequestScoped))), + Effect.provide( + Layer.effect( + Database.DatabaseConnection, + Effect.acquireRelease( + Effect.sync(() => Database.makeRequestConnection(connectionString)), + (connection) => Effect.promise(() => connection.end()), + ), + ), + ), + Effect.withSpan("BotGateway.authenticate"), + ) diff --git a/apps/bot-gateway/src/gateway/event-store.test.ts b/apps/bot-gateway/src/gateway/event-store.test.ts new file mode 100644 index 000000000..459879b81 --- /dev/null +++ b/apps/bot-gateway/src/gateway/event-store.test.ts @@ -0,0 +1,131 @@ +import { DatabaseSync } from "node:sqlite" +import { describe, expect, it } from "vitest" +import * as Store from "./event-store.ts" +import { + DEFAULT_BATCH_LIMITS, + evaluateAck, + formatOffset, + parseResumeOffset, + resolveCursor, + selectBatch, + type SessionCursor, +} from "./log.ts" + +/** `node:sqlite` behind the synchronous `SqlStorageLike` the object uses (workerd's shape). */ +const memorySql = (): Store.SqlStorageLike => { + const db = new DatabaseSync(":memory:") + return { + exec: (query, ...bindings) => { + const statement = db.prepare(query) + if (/^\s*select/i.test(query)) { + const rows = statement.all(...(bindings as Array)) + return { toArray: () => rows as never } + } + statement.run(...(bindings as Array)) + return { toArray: () => [] } + }, + } +} + +const freshStore = () => { + const sql = memorySql() + Store.migrate(sql) + // Migrating twice is a no-op (every activation migrates). + Store.migrate(sql) + return sql +} + +const body = (n: number) => JSON.stringify({ n }) + +describe("event store", () => { + it("assigns monotonic sequence numbers that survive trimming", () => { + const sql = freshStore() + expect(Store.bounds(sql)).toEqual({ head: 0, firstRetained: undefined }) + expect([1, 2, 3].map((n) => Store.append(sql, body(n), 1_000))).toEqual([1, 2, 3]) + + Store.trimThrough(sql, 3) + expect(Store.bounds(sql)).toEqual({ head: 3, firstRetained: undefined }) + expect(Store.append(sql, body(4), 1_000)).toBe(4) + expect(Store.bounds(sql)).toEqual({ head: 4, firstRetained: 4 }) + }) + + it("reads strictly after a cursor, in order, up to a limit", () => { + const sql = freshStore() + for (let n = 1; n <= 5; n++) Store.append(sql, body(n), 1_000) + expect(Store.readAfter(sql, 2, 10).map((e) => e.seq)).toEqual([3, 4, 5]) + expect(Store.readAfter(sql, 0, 2)).toEqual([ + { seq: 1, body: body(1) }, + { seq: 2, body: body(2) }, + ]) + expect(Store.readAfter(sql, 5, 10)).toEqual([]) + }) + + it("drops events past the count and age limits", () => { + const sql = freshStore() + for (let n = 1; n <= 5; n++) Store.append(sql, body(n), n * 1_000) + Store.enforceRetention(sql, 5_000, { maxEvents: 3, maxAgeMs: 60_000 }) + expect(Store.bounds(sql)).toEqual({ head: 5, firstRetained: 3 }) + + Store.enforceRetention(sql, 64_000, { maxEvents: 3, maxAgeMs: 60_000 }) + expect(Store.bounds(sql).firstRetained).toBe(5) + expect(Store.oldestAppendedAt(sql)).toBe(5_000) + }) +}) + +describe("delivery: offsets, replay and acks", () => { + /** One delivery step as the object runs it: the next batch after the cursor. */ + const nextBatch = (sql: Store.SqlStorageLike, session: SessionCursor) => + selectBatch(Store.readAfter(sql, session.cursor, DEFAULT_BATCH_LIMITS.maxEvents), { + maxEvents: 2, + maxBytes: DEFAULT_BATCH_LIMITS.maxBytes, + }) + + it("replays unacknowledged events on reconnect and trims acknowledged ones", () => { + const sql = freshStore() + for (let n = 1; n <= 5; n++) Store.append(sql, body(n), 1_000) + + // First connection, replaying everything retained. + let session: SessionCursor = { + sessionId: "s1", + cursor: resolveCursor(parseResumeOffset("-1"), Store.bounds(sql)), + pending: null, + pendingDeadline: null, + } + const first = nextBatch(sql, session) + expect(first.map((e) => e.seq)).toEqual([1, 2]) + session = { ...session, pending: 2 } + + const ack = evaluateAck(session, { sessionId: "s1", nextOffset: formatOffset(2) }) + expect(ack._tag).toBe("Accepted") + if (ack._tag !== "Accepted") return + Store.trimThrough(sql, ack.cursor) + session = { ...session, cursor: ack.cursor, pending: null } + expect(Store.bounds(sql)).toEqual({ head: 5, firstRetained: 3 }) + + // Second batch goes out but the bot drops before acknowledging it. + expect(nextBatch(sql, session).map((e) => e.seq)).toEqual([3, 4]) + + // It reconnects from its last acknowledged offset and gets the same batch again. + const resumed: SessionCursor = { + sessionId: "s1", + cursor: resolveCursor(parseResumeOffset(formatOffset(2)), Store.bounds(sql)), + pending: null, + pendingDeadline: null, + } + expect(nextBatch(sql, resumed).map((e) => e.seq)).toEqual([3, 4]) + }) + + it("tails only new events for `now`", () => { + const sql = freshStore() + for (let n = 1; n <= 3; n++) Store.append(sql, body(n), 1_000) + const session: SessionCursor = { + sessionId: "s1", + cursor: resolveCursor(parseResumeOffset("now"), Store.bounds(sql)), + pending: null, + pendingDeadline: null, + } + expect(nextBatch(sql, session)).toEqual([]) + Store.append(sql, body(4), 1_000) + expect(nextBatch(sql, session).map((e) => e.seq)).toEqual([4]) + }) +}) diff --git a/apps/bot-gateway/src/gateway/event-store.ts b/apps/bot-gateway/src/gateway/event-store.ts new file mode 100644 index 000000000..fe302ef82 --- /dev/null +++ b/apps/bot-gateway/src/gateway/event-store.ts @@ -0,0 +1,100 @@ +/** + * The append-only event log in the `BotGateway` object's SQLite storage. Written against the + * synchronous subset of workerd's `SqlStorage` the object uses, so tests can back it with any + * SQLite. Every function runs synchronously, so inside the object each call is atomic. + */ +import { type LogBounds, type RetentionPolicy, retentionFloor, type Seq, type StoredEvent } from "./log.ts" + +type SqlValue = string | number | null | ArrayBuffer + +export interface SqlCursorLike { + toArray(): T[] +} + +/** The part of workerd's `ctx.storage.sql` this store needs. */ +export interface SqlStorageLike { + exec>(query: string, ...bindings: Array): SqlCursorLike +} + +const SCHEMA = [ + `CREATE TABLE IF NOT EXISTS bot_gateway_events ( + seq INTEGER PRIMARY KEY, + body TEXT NOT NULL, + appended_at INTEGER NOT NULL + )`, + // `head` survives trimming, so sequence numbers are never reused. + `CREATE TABLE IF NOT EXISTS bot_gateway_meta ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL + )`, +] + +export const migrate = (sql: SqlStorageLike): void => { + for (const statement of SCHEMA) sql.exec(statement) +} + +const readHead = (sql: SqlStorageLike): Seq => { + const row = sql + .exec<{ value: number }>(`SELECT value FROM bot_gateway_meta WHERE key = 'head'`) + .toArray()[0] + return row === undefined ? 0 : Number(row.value) +} + +export const bounds = (sql: SqlStorageLike): LogBounds => { + const row = sql + .exec<{ first: number | null }>(`SELECT MIN(seq) AS first FROM bot_gateway_events`) + .toArray()[0] + const first = row?.first + return { + head: readHead(sql), + firstRetained: first === null || first === undefined ? undefined : Number(first), + } +} + +/** Append one event; returns its sequence number. */ +export const append = (sql: SqlStorageLike, body: string, appendedAt: number): Seq => { + const seq = readHead(sql) + 1 + sql.exec( + `INSERT INTO bot_gateway_events (seq, body, appended_at) VALUES (?, ?, ?)`, + seq, + body, + appendedAt, + ) + sql.exec( + `INSERT INTO bot_gateway_meta (key, value) VALUES ('head', ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value`, + seq, + ) + return seq +} + +/** Up to `limit` events after `cursor`, in log order. */ +export const readAfter = (sql: SqlStorageLike, cursor: Seq, limit: number): ReadonlyArray => + sql + .exec<{ seq: number; body: string }>( + `SELECT seq, body FROM bot_gateway_events WHERE seq > ? ORDER BY seq LIMIT ?`, + cursor, + limit, + ) + .toArray() + .map((row) => ({ seq: Number(row.seq), body: String(row.body) })) + +/** Drop every event at or before `seq` (it has been acknowledged). */ +export const trimThrough = (sql: SqlStorageLike, seq: Seq): void => { + sql.exec(`DELETE FROM bot_gateway_events WHERE seq <= ?`, seq) +} + +/** Drop events past the retention policy's count and age limits. */ +export const enforceRetention = (sql: SqlStorageLike, now: number, policy: RetentionPolicy): void => { + const floor = retentionFloor(readHead(sql), policy) + if (floor > 0) trimThrough(sql, floor) + sql.exec(`DELETE FROM bot_gateway_events WHERE appended_at <= ?`, now - policy.maxAgeMs) +} + +export const oldestAppendedAt = (sql: SqlStorageLike): number | undefined => { + const row = sql + .exec<{ oldest: number | null }>(`SELECT MIN(appended_at) AS oldest FROM bot_gateway_events`) + .toArray()[0] + const oldest = row?.oldest + return oldest === null || oldest === undefined ? undefined : Number(oldest) +} diff --git a/apps/bot-gateway/src/gateway/handshake.ts b/apps/bot-gateway/src/gateway/handshake.ts new file mode 100644 index 000000000..3cfedcb66 --- /dev/null +++ b/apps/bot-gateway/src/gateway/handshake.ts @@ -0,0 +1,56 @@ +/** + * The internal handshake from the gateway Worker to a `BotGateway` object. Bots send their token + * inside the first WebSocket frame (IDENTIFY/RESUME), not on the upgrade request, so the Worker + * accepts the socket, authenticates that frame, and only then opens a WebSocket to the bot's + * object, describing the session in these headers. The object is reachable only through its + * namespace binding, so it can trust them. + */ +import type { SessionRequest } from "./log.ts" + +export const HANDSHAKE_HEADERS = { + botId: "x-hazel-bot-id", + botName: "x-hazel-bot-name", + op: "x-hazel-gateway-op", + sessionId: "x-hazel-gateway-session-id", + resumeOffset: "x-hazel-gateway-resume-offset", +} as const + +export interface Handshake { + readonly botId: string + readonly botName: string + readonly request: SessionRequest +} + +export const encodeHandshake = (handshake: Handshake): Record => ({ + [HANDSHAKE_HEADERS.botId]: handshake.botId, + // Header values must be ByteStrings; bot names are free text. + [HANDSHAKE_HEADERS.botName]: encodeURIComponent(handshake.botName), + [HANDSHAKE_HEADERS.op]: handshake.request.op, + [HANDSHAKE_HEADERS.resumeOffset]: encodeURIComponent(handshake.request.resumeOffset), + ...(handshake.request.op === "RESUME" + ? { [HANDSHAKE_HEADERS.sessionId]: handshake.request.sessionId } + : {}), +}) + +const safeDecode = (value: string): string | undefined => { + try { + return decodeURIComponent(value) + } catch { + return undefined + } +} + +/** The handshake from request headers (lower-cased keys), or `undefined` if malformed. */ +export const decodeHandshake = ( + headers: Readonly>, +): Handshake | undefined => { + const botId = headers[HANDSHAKE_HEADERS.botId] + const botName = safeDecode(headers[HANDSHAKE_HEADERS.botName] ?? "") + const op = headers[HANDSHAKE_HEADERS.op] + const resumeOffset = safeDecode(headers[HANDSHAKE_HEADERS.resumeOffset] ?? "") + if (!botId || botName === undefined || resumeOffset === undefined) return undefined + if (op === "IDENTIFY") return { botId, botName, request: { op, resumeOffset } } + const sessionId = headers[HANDSHAKE_HEADERS.sessionId] + if (op === "RESUME" && sessionId) return { botId, botName, request: { op, sessionId, resumeOffset } } + return undefined +} diff --git a/apps/bot-gateway/src/gateway/log.test.ts b/apps/bot-gateway/src/gateway/log.test.ts new file mode 100644 index 000000000..912738c59 --- /dev/null +++ b/apps/bot-gateway/src/gateway/log.test.ts @@ -0,0 +1,179 @@ +import { BotGatewayServerFrame } from "@hazel/domain" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" +import { + decideAdmission, + encodeDispatchFrame, + evaluateAck, + formatOffset, + nextAlarmAt, + parseOffset, + parseResumeOffset, + resolveCursor, + retentionFloor, + SESSION_CONFLICT_REASON, + selectBatch, + type SessionCursor, + type StoredEvent, +} from "./log.ts" + +const envelope = (n: number) => ({ + schemaVersion: 1, + deliveryId: `delivery-${n}`, + partitionKey: "org:00000000-0000-4000-8000-000000000333:channel:00000000-0000-4000-8000-000000000444", + occurredAt: 1_700_000_000_000 + n, + idempotencyKey: `command:${n}`, + eventType: "command.invoke", + payload: { + commandName: "echo", + channelId: "00000000-0000-4000-8000-000000000444", + userId: "00000000-0000-4000-8000-000000000222", + orgId: "00000000-0000-4000-8000-000000000333", + arguments: { text: `hello ${n}` }, + timestamp: 1_700_000_000_000 + n, + }, +}) + +const event = (seq: number): StoredEvent => ({ seq, body: JSON.stringify(envelope(seq)) }) + +describe("offsets", () => { + it("formats fixed-width offsets that sort as strings", () => { + expect(formatOffset(0)).toBe("0000000000000000") + expect(formatOffset(42)).toBe("0000000000000042") + expect(formatOffset(9) < formatOffset(10)).toBe(true) + }) + + it("round-trips offsets it issued and rejects foreign ones", () => { + expect(parseOffset(formatOffset(1234))).toBe(1234) + expect(parseOffset("17")).toBe(17) + expect(parseOffset("-1")).toBeUndefined() + expect(parseOffset("now")).toBeUndefined() + expect(parseOffset("0000000000000000_0000000000000123")).toBeUndefined() + expect(parseOffset("12345678901234567")).toBeUndefined() + }) + + it("reads the SDK's special resume offsets", () => { + expect(parseResumeOffset("now")).toEqual({ _tag: "Latest" }) + expect(parseResumeOffset("-1")).toEqual({ _tag: "Earliest" }) + expect(parseResumeOffset(formatOffset(7))).toEqual({ _tag: "After", seq: 7 }) + // A Durable Streams offset saved before the cutover replays what is retained. + expect(parseResumeOffset("0000000000000000_0000000000000123")).toEqual({ _tag: "Earliest" }) + }) +}) + +describe("resolveCursor", () => { + const bounds = { head: 10, firstRetained: 6 } + + it("tails from the head for `now`", () => { + expect(resolveCursor({ _tag: "Latest" }, bounds)).toBe(10) + }) + + it("replays everything retained for `-1`", () => { + expect(resolveCursor({ _tag: "Earliest" }, bounds)).toBe(5) + expect(resolveCursor({ _tag: "Earliest" }, { head: 10, firstRetained: undefined })).toBe(10) + expect(resolveCursor({ _tag: "Earliest" }, { head: 0, firstRetained: undefined })).toBe(0) + }) + + it("resumes after the client's offset", () => { + expect(resolveCursor({ _tag: "After", seq: 8 }, bounds)).toBe(8) + // Older than what is retained: reads start at the first retained event anyway. + expect(resolveCursor({ _tag: "After", seq: 2 }, bounds)).toBe(2) + }) + + it("clamps an offset past the head (the log was reset)", () => { + expect(resolveCursor({ _tag: "After", seq: 500 }, bounds)).toBe(10) + }) +}) + +describe("selectBatch", () => { + it("caps by event count", () => { + const events = Array.from({ length: 5 }, (_, i) => event(i + 1)) + expect(selectBatch(events, { maxEvents: 2, maxBytes: 1_000_000 }).map((e) => e.seq)).toEqual([1, 2]) + }) + + it("caps by bytes but always sends at least one event", () => { + const events = [event(1), event(2), event(3)] + const one = events[0]!.body.length + expect(selectBatch(events, { maxEvents: 10, maxBytes: one * 2 }).map((e) => e.seq)).toEqual([1, 2]) + expect(selectBatch(events, { maxEvents: 10, maxBytes: 1 }).map((e) => e.seq)).toEqual([1]) + expect(selectBatch([], { maxEvents: 10, maxBytes: 1 })).toEqual([]) + }) +}) + +describe("encodeDispatchFrame", () => { + it("produces a DISPATCH frame the SDK decodes, with the last event's offset", () => { + const frame = encodeDispatchFrame("session-1", [event(3), event(4)]) + const decoded = Schema.decodeUnknownSync(BotGatewayServerFrame)(JSON.parse(frame)) + expect(decoded.op).toBe("DISPATCH") + if (decoded.op !== "DISPATCH") return + expect(decoded.sessionId).toBe("session-1") + expect(decoded.nextOffset).toBe(formatOffset(4)) + expect(decoded.events.map((e) => e.deliveryId)).toEqual(["delivery-3", "delivery-4"]) + }) + + it("refuses an empty batch", () => { + expect(() => encodeDispatchFrame("session-1", [])).toThrow() + }) +}) + +describe("evaluateAck", () => { + const session: SessionCursor = { sessionId: "s1", cursor: 4, pending: 9, pendingDeadline: 1 } + + it("accepts exactly the in-flight batch on its session", () => { + expect(evaluateAck(session, { sessionId: "s1", nextOffset: formatOffset(9) })).toEqual({ + _tag: "Accepted", + cursor: 9, + }) + }) + + it("ignores stale, foreign or unexpected ACKs", () => { + expect(evaluateAck(session, { sessionId: "s1", nextOffset: formatOffset(8) })).toMatchObject({ + reason: "offset_mismatch", + }) + expect(evaluateAck(session, { sessionId: "s2", nextOffset: formatOffset(9) })).toMatchObject({ + reason: "session_mismatch", + }) + expect( + evaluateAck({ ...session, pending: null }, { sessionId: "s1", nextOffset: formatOffset(9) }), + ).toMatchObject({ reason: "no_pending_batch" }) + }) +}) + +describe("decideAdmission", () => { + it("accepts when no session is live", () => { + expect(decideAdmission(undefined, { op: "IDENTIFY", resumeOffset: "now" })).toEqual({ + _tag: "Accept", + }) + }) + + it("lets the live session's own RESUME replace its socket", () => { + expect( + decideAdmission({ sessionId: "s1" }, { op: "RESUME", sessionId: "s1", resumeOffset: "now" }), + ).toEqual({ _tag: "Replace", previousSessionId: "s1" }) + }) + + it("rejects any other connection with the lease's reason", () => { + expect(decideAdmission({ sessionId: "s1" }, { op: "IDENTIFY", resumeOffset: "now" })).toEqual({ + _tag: "Reject", + reason: SESSION_CONFLICT_REASON, + }) + expect( + decideAdmission({ sessionId: "s1" }, { op: "RESUME", sessionId: "s2", resumeOffset: "now" }), + ).toMatchObject({ _tag: "Reject" }) + }) +}) + +describe("retention and alarms", () => { + const policy = { maxEvents: 100, maxAgeMs: 1_000 } + + it("keeps the newest maxEvents", () => { + expect(retentionFloor(50, policy)).toBe(0) + expect(retentionFloor(150, policy)).toBe(50) + }) + + it("wakes for the earliest ACK deadline or retention expiry", () => { + expect(nextAlarmAt([], undefined, policy)).toBeUndefined() + expect(nextAlarmAt([5_000, 3_000], undefined, policy)).toBe(3_000) + expect(nextAlarmAt([5_000], 1_000, policy)).toBe(2_000) + }) +}) diff --git a/apps/bot-gateway/src/gateway/log.ts b/apps/bot-gateway/src/gateway/log.ts new file mode 100644 index 000000000..8e17c8406 --- /dev/null +++ b/apps/bot-gateway/src/gateway/log.ts @@ -0,0 +1,224 @@ +/** + * The `BotGateway` Durable Object's delivery rules, as pure functions over plain values so they + * test without workerd. The object (`object-live.ts`) and the SQLite store (`event-store.ts`) + * only wire these to storage and sockets. + * + * ## Offsets + * + * Every event appended to a bot's log gets the next sequence number (1, 2, 3, ...); numbers are + * never reused, even after the rows are trimmed. On the wire an offset is the sequence number of + * the last event a client has consumed, zero-padded to {@link OFFSET_WIDTH} digits so offsets + * also compare as strings. Reading "from" an offset returns the events strictly after it, and a + * batch's `nextOffset` is its last event's offset: the same exclusive-cursor semantics the + * Durable Streams server had, so `libs/bot-sdk` saves and resumes offsets unchanged. + * + * Special client offsets, as the SDK documents them: `"-1"` replays everything still retained, + * `"now"` tails only new events. An offset this log never issued (for example one saved from the + * Durable Streams server before the cutover) replays everything retained, favouring at-least-once + * delivery over silently skipping events. An offset ahead of the log's head (the object's storage + * was reset) is clamped to the head. + * + * ## Acks and trimming + * + * At most one batch is in flight per session. The session's cursor advances only when the client + * ACKs exactly the in-flight batch's `nextOffset` (any other ACK is ignored, as before), and an + * accepted ACK trims every event at or before it: with one consumer per bot, an acknowledged + * event is never needed again. Unacknowledged events are bounded by {@link DEFAULT_RETENTION}. + */ + +/** Digits in a wire offset. */ +export const OFFSET_WIDTH = 16 + +/** Sequence number of the last consumed event; `0` before the first event. */ +export type Seq = number + +export const formatOffset = (seq: Seq): string => String(seq).padStart(OFFSET_WIDTH, "0") + +const OFFSET_PATTERN = /^\d{1,16}$/ + +/** A wire offset this log issued, or `undefined`. */ +export const parseOffset = (raw: string): Seq | undefined => { + if (!OFFSET_PATTERN.test(raw)) return undefined + const seq = Number(raw) + return Number.isSafeInteger(seq) ? seq : undefined +} + +/** Where a client asked to resume from. */ +export type ResumePoint = + | { readonly _tag: "Earliest" } + | { readonly _tag: "Latest" } + | { readonly _tag: "After"; readonly seq: Seq } + +export const parseResumeOffset = (raw: string): ResumePoint => { + const trimmed = raw.trim() + if (trimmed === "now") return { _tag: "Latest" } + if (trimmed === "-1") return { _tag: "Earliest" } + const seq = parseOffset(trimmed) + return seq === undefined ? { _tag: "Earliest" } : { _tag: "After", seq } +} + +/** The log's extent: `head` is the last sequence number ever assigned (`0` for a fresh log). */ +export interface LogBounds { + readonly head: Seq + /** The oldest retained event, or `undefined` when every event has been trimmed. */ + readonly firstRetained: Seq | undefined +} + +/** The session cursor (last consumed sequence number) a resume point starts from. */ +export const resolveCursor = (point: ResumePoint, bounds: LogBounds): Seq => { + switch (point._tag) { + case "Latest": + return bounds.head + case "Earliest": + return bounds.firstRetained === undefined ? bounds.head : bounds.firstRetained - 1 + case "After": + return Math.min(Math.max(point.seq, 0), bounds.head) + } +} + +export interface StoredEvent { + readonly seq: Seq + /** The envelope as JSON text, exactly as published. */ + readonly body: string +} + +export interface BatchLimits { + readonly maxEvents: number + readonly maxBytes: number +} + +/** Batch caps: far below the 1 MiB WebSocket message limit, and a size bots process promptly. */ +export const DEFAULT_BATCH_LIMITS: BatchLimits = { maxEvents: 100, maxBytes: 512 * 1024 } + +/** + * The prefix of `events` (in log order) that fits `limits`. Never empty when `events` is not, so + * one oversized event still goes out on its own instead of wedging the session. + */ +export const selectBatch = ( + events: ReadonlyArray, + limits: BatchLimits = DEFAULT_BATCH_LIMITS, +): ReadonlyArray => { + const batch: Array = [] + let bytes = 0 + for (const event of events) { + if (batch.length >= limits.maxEvents) break + const size = event.body.length + if (batch.length > 0 && bytes + size > limits.maxBytes) break + batch.push(event) + bytes += size + } + return batch +} + +/** + * The DISPATCH frame for a batch, built by splicing the stored JSON bodies (validated when they + * were published) instead of parsing and re-serialising every event. + */ +export const encodeDispatchFrame = (sessionId: string, batch: ReadonlyArray): string => { + const last = batch[batch.length - 1] + if (last === undefined) throw new Error("encodeDispatchFrame: empty batch") + return `{"op":"DISPATCH","sessionId":${JSON.stringify(sessionId)},"events":[${batch + .map((event) => event.body) + .join(",")}],"nextOffset":${JSON.stringify(formatOffset(last.seq))}}` +} + +/** The delivery state a session carries (in its socket attachment, so it survives hibernation). */ +export interface SessionCursor { + readonly sessionId: string + readonly cursor: Seq + /** The in-flight batch's last sequence number, or `null` when nothing awaits an ACK. */ + readonly pending: Seq | null + /** Epoch ms after which an unacknowledged batch ends the session. */ + readonly pendingDeadline: number | null +} + +export type AckOutcome = + | { readonly _tag: "Accepted"; readonly cursor: Seq } + | { + readonly _tag: "Ignored" + readonly reason: "no_pending_batch" | "session_mismatch" | "offset_mismatch" + } + +/** Today's ACK rule: only an ACK for exactly the in-flight batch, on its own session, counts. */ +export const evaluateAck = ( + session: SessionCursor, + ack: { readonly sessionId: string; readonly nextOffset: string }, +): AckOutcome => { + if (session.pending === null) return { _tag: "Ignored", reason: "no_pending_batch" } + if (ack.sessionId !== session.sessionId) return { _tag: "Ignored", reason: "session_mismatch" } + if (ack.nextOffset !== formatOffset(session.pending)) { + return { _tag: "Ignored", reason: "offset_mismatch" } + } + return { _tag: "Accepted", cursor: session.pending } +} + +/** The handshake the gateway Worker forwards once it has authenticated a bot. */ +export type SessionRequest = + | { readonly op: "IDENTIFY"; readonly resumeOffset: string } + | { readonly op: "RESUME"; readonly sessionId: string; readonly resumeOffset: string } + +export type Admission = + | { readonly _tag: "Accept" } + | { readonly _tag: "Replace"; readonly previousSessionId: string } + | { readonly _tag: "Reject"; readonly reason: string } + +/** The reason the Bun gateway gave when the Redis lease was held; bots log it verbatim. */ +export const SESSION_CONFLICT_REASON = "another active session already owns this bot token" + +/** + * Second-connection policy, matching the Redis lease it replaces. The lease was keyed by session + * id: the holder's own id renewed it, anyone else was refused until it expired. So: + * + * - no live session: accept; + * - a RESUME of the live session's own id: replace the old socket (the bot reconnected and the + * old socket is a half-open leftover; the old lease would have accepted the same id too); + * - anything else: reject with {@link SESSION_CONFLICT_REASON}. + * + * Rejecting rather than "newest wins" avoids two bot processes with the same token kicking each + * other off in a loop. The lease TTL's role, freeing the bot after a client vanishes, is played + * by the gateway Worker's heartbeat watchdog, which closes a silent client's upstream socket + * after the lease TTL. + */ +export const decideAdmission = ( + live: { readonly sessionId: string } | undefined, + request: SessionRequest, +): Admission => { + if (live === undefined) return { _tag: "Accept" } + if (request.op === "RESUME" && request.sessionId === live.sessionId) { + return { _tag: "Replace", previousSessionId: live.sessionId } + } + return { _tag: "Reject", reason: SESSION_CONFLICT_REASON } +} + +export interface RetentionPolicy { + /** Unacknowledged events kept per bot; older ones are dropped on append. */ + readonly maxEvents: number + /** Age after which an unacknowledged event is dropped. */ + readonly maxAgeMs: number +} + +/** + * Bounds what an offline bot can accumulate. Durable Streams kept everything forever; with one + * consumer per log, events nobody acknowledges within a week (or past 10k) are dropped. + */ +export const DEFAULT_RETENTION: RetentionPolicy = { maxEvents: 10_000, maxAgeMs: 7 * 24 * 60 * 60 * 1000 } + +/** Sequence numbers at or below this fall outside `maxEvents`; `0` keeps everything. */ +export const retentionFloor = (head: Seq, policy: RetentionPolicy): Seq => + Math.max(0, head - policy.maxEvents) + +/** + * When the object must next wake: the earliest ACK deadline, or when the oldest retained event + * expires. `undefined` when nothing is scheduled. + */ +export const nextAlarmAt = ( + pendingDeadlines: ReadonlyArray, + oldestAppendedAt: number | undefined, + policy: RetentionPolicy, +): number | undefined => { + const candidates = [ + ...pendingDeadlines, + ...(oldestAppendedAt === undefined ? [] : [oldestAppendedAt + policy.maxAgeMs]), + ] + return candidates.length === 0 ? undefined : Math.min(...candidates) +} diff --git a/apps/bot-gateway/src/gateway/object-live.ts b/apps/bot-gateway/src/gateway/object-live.ts new file mode 100644 index 000000000..2b9564178 --- /dev/null +++ b/apps/bot-gateway/src/gateway/object-live.ts @@ -0,0 +1,319 @@ +/** + * The `BotGateway` Durable Object: one per bot. It owns the bot's append-only event log (SQLite) + * and its single WebSocket session (hibernatable, opened by the gateway Worker's relay once the + * bot is authenticated, see `relay.ts`). + * + * - `publish` (RPC, from the backend) appends an event and dispatches it if the session is idle. + * - The session gets one DISPATCH batch at a time; its ACK advances the cursor, trims the log and + * sends the next batch. An ACK not received within the batch timeout ends the session with + * RECONNECT (an alarm enforces it), and the bot resumes from its last acknowledged offset. + * - Session state (cursor, in-flight batch) lives in the socket attachment, so it survives + * hibernation and dies with the socket. The log lives in SQLite and survives everything. + * + * Offset, ACK, admission and retention rules are in `log.ts`. + */ +import { + BotGatewayClientFrame, + BotGatewayEnvelope, + BotGatewayEventRejectedError, + type BotGatewayHeartbeatAckFrame, + type BotGatewayInvalidSessionFrame, + type BotGatewayPublishResult, + type BotGatewayReadyFrame, + type BotGatewayReconnectFrame, +} from "@hazel/domain" +import * as Cloudflare from "alchemy/Cloudflare" +import { Effect, Option, Schema } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/http" +import { BotGatewayObject } from "../object.ts" +import * as Store from "./event-store.ts" +import { decodeHandshake } from "./handshake.ts" +import { + DEFAULT_BATCH_LIMITS, + DEFAULT_RETENTION, + decideAdmission, + encodeDispatchFrame, + evaluateAck, + formatOffset, + nextAlarmAt, + parseResumeOffset, + resolveCursor, + selectBatch, + type SessionCursor, +} from "./log.ts" +import { readGatewaySettings } from "./settings.ts" + +/** Persisted on the session's socket (`serializeAttachment`, 2 KiB max). */ +interface SessionAttachment extends SessionCursor { + readonly v: 1 + readonly botId: string + readonly botName: string +} + +const isSessionAttachment = (value: unknown): value is SessionAttachment => + typeof value === "object" && value !== null && (value as { v?: unknown }).v === 1 + +/** The workerd socket calls this object makes (a subset of `WebSocket`). */ +interface RawSocket { + readonly readyState: number + send(data: string): void + close(code?: number, reason?: string): void + serializeAttachment(value: unknown): void + deserializeAttachment(): unknown +} + +interface LiveSession { + readonly socket: RawSocket + readonly session: SessionAttachment +} + +const WEBSOCKET_OPEN = 1 + +const decodeEnvelope = Schema.decodeUnknownEffect(BotGatewayEnvelope) +const decodeClientFrame = Schema.decodeUnknownOption(BotGatewayClientFrame) + +const parseJson = (text: string): unknown => { + try { + return JSON.parse(text) + } catch { + return undefined + } +} + +const sendFrame = (socket: RawSocket, frame: object | string) => { + try { + socket.send(typeof frame === "string" ? frame : JSON.stringify(frame)) + } catch { + // The socket is closing; its close event ends the session. + } +} + +const closeSocket = (socket: RawSocket, code: number, reason: string) => { + try { + socket.close(code, reason) + } catch { + // Already closed. + } +} + +const invalidSession = (socket: RawSocket, reason: string) => { + sendFrame(socket, { op: "INVALID_SESSION", reason } satisfies typeof BotGatewayInvalidSessionFrame.Type) + closeSocket(socket, 1008, reason.slice(0, 120)) +} + +/** The activation, in alchemy's two phases; the outer one also runs at plan time, against a mock. */ +export const activateBotGateway = Effect.gen(function* () { + const state = yield* Cloudflare.DurableObjectState + const env = yield* Cloudflare.WorkerEnvironment + + return Effect.gen(function* () { + const raw = state.raw + const sql = raw.storage.sql as unknown as Store.SqlStorageLike + const settings = readGatewaySettings(env) + yield* Effect.sync(() => Store.migrate(sql)) + + const liveSessions = (): Array => + (raw.getWebSockets() as unknown as Array).flatMap((socket) => { + if (socket.readyState !== WEBSOCKET_OPEN) return [] + const session = socket.deserializeAttachment() + return isSessionAttachment(session) ? [{ socket, session }] : [] + }) + + /** Send the next batch if nothing is in flight; returns the session's new state. */ + const deliver = (socket: RawSocket, session: SessionAttachment, now: number): SessionAttachment => { + if (session.pending !== null) return session + const batch = selectBatch(Store.readAfter(sql, session.cursor, DEFAULT_BATCH_LIMITS.maxEvents)) + const last = batch[batch.length - 1] + if (last === undefined) return session + const next: SessionAttachment = { + ...session, + pending: last.seq, + pendingDeadline: now + settings.batchAckTimeoutMs, + } + socket.serializeAttachment(next) + sendFrame(socket, encodeDispatchFrame(session.sessionId, batch)) + return next + } + + /** Arm the single native alarm for the earliest ACK deadline or retention expiry. */ + const scheduleAlarm = Effect.promise(async () => { + const target = nextAlarmAt( + liveSessions().flatMap(({ session }) => + session.pendingDeadline === null ? [] : [session.pendingDeadline], + ), + Store.oldestAppendedAt(sql), + DEFAULT_RETENTION, + ) + if (target === undefined) return + const current = await raw.storage.getAlarm() + if (current === null || current > target) await raw.storage.setAlarm(target) + }) + + const publish = (eventJson: string) => + Effect.gen(function* () { + const parsed = parseJson(eventJson) + if (parsed === undefined) { + return yield* new BotGatewayEventRejectedError({ message: "Event is not valid JSON" }) + } + const envelope = yield* decodeEnvelope(parsed).pipe( + Effect.mapError( + (issue) => + new BotGatewayEventRejectedError({ message: `Invalid event envelope: ${issue}` }), + ), + ) + const now = Date.now() + const seq = Store.append(sql, eventJson, now) + Store.enforceRetention(sql, now, DEFAULT_RETENTION) + for (const { socket, session } of liveSessions()) deliver(socket, session, now) + yield* scheduleAlarm + yield* Effect.annotateCurrentSpan({ + "bot_gateway.offset": seq, + "bot_gateway.event_type": envelope.eventType, + }) + return { offset: formatOffset(seq) } satisfies BotGatewayPublishResult + }).pipe(Effect.withSpan("BotGateway.publish")) + + const fetch = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const handshake = decodeHandshake(request.headers) + if (handshake === undefined) { + return HttpServerResponse.text("Invalid gateway handshake", { status: 400 }) + } + if (request.headers.upgrade?.toLowerCase() !== "websocket") { + return HttpServerResponse.text("Expected a WebSocket upgrade", { status: 426 }) + } + + const live = liveSessions() + const admission = decideAdmission(live[0]?.session, handshake.request) + if (admission._tag === "Reject") { + yield* Effect.logInfo("Bot gateway session rejected", { + botId: handshake.botId, + reason: admission.reason, + }) + return HttpServerResponse.jsonUnsafe({ reason: admission.reason }, { status: 409 }) + } + if (admission._tag === "Replace") { + for (const { socket } of live) invalidSession(socket, "session resumed by another connection") + } + + const now = Date.now() + const { request: sessionRequest } = handshake + const session: SessionAttachment = { + v: 1, + sessionId: sessionRequest.op === "RESUME" ? sessionRequest.sessionId : crypto.randomUUID(), + botId: handshake.botId, + botName: handshake.botName, + cursor: resolveCursor(parseResumeOffset(sessionRequest.resumeOffset), Store.bounds(sql)), + pending: null, + pendingDeadline: null, + } + const [response, socket] = yield* Cloudflare.upgrade() + const rawSocket = socket.ws as unknown as RawSocket + rawSocket.serializeAttachment(session) + sendFrame(rawSocket, { + op: "READY", + sessionId: session.sessionId, + resumed: sessionRequest.op === "RESUME", + resumeOffset: sessionRequest.resumeOffset, + } satisfies typeof BotGatewayReadyFrame.Type) + deliver(rawSocket, session, now) + yield* scheduleAlarm + yield* Effect.logInfo("Bot gateway session ready", { + sessionId: session.sessionId, + botId: session.botId, + botName: session.botName, + resumed: sessionRequest.op === "RESUME", + resumeOffset: sessionRequest.resumeOffset, + cursor: formatOffset(session.cursor), + }) + return response + }).pipe(Effect.withSpan("BotGateway.connect")) + + const webSocketMessage = (socket: Cloudflare.WebSocket, message: string | ArrayBuffer) => + Effect.gen(function* () { + const rawSocket = socket.ws as unknown as RawSocket + const session = rawSocket.deserializeAttachment() + if (!isSessionAttachment(session)) { + closeSocket(rawSocket, 1011, "session_state_lost") + return + } + const text = typeof message === "string" ? message : new TextDecoder().decode(message) + const frame = Option.getOrUndefined(decodeClientFrame(parseJson(text))) + if (frame === undefined) return invalidSession(rawSocket, "Failed to decode gateway frame") + + switch (frame.op) { + case "ACK": { + const outcome = evaluateAck(session, frame) + if (outcome._tag === "Ignored") { + yield* Effect.logDebug("Bot gateway ACK ignored", { + sessionId: session.sessionId, + reason: outcome.reason, + }) + return + } + Store.trimThrough(sql, outcome.cursor) + const acked: SessionAttachment = { + ...session, + cursor: outcome.cursor, + pending: null, + pendingDeadline: null, + } + rawSocket.serializeAttachment(acked) + deliver(rawSocket, acked, Date.now()) + yield* scheduleAlarm + return + } + case "HEARTBEAT": { + // The relay answers heartbeats; this covers a client reaching the object directly. + sendFrame(rawSocket, { + op: "HEARTBEAT_ACK", + sessionId: session.sessionId, + } satisfies typeof BotGatewayHeartbeatAckFrame.Type) + return + } + case "IDENTIFY": + case "RESUME": + return invalidSession(rawSocket, "session already identified") + } + }) + + const webSocketClose = (socket: Cloudflare.WebSocket, code: number, reason: string) => + Effect.gen(function* () { + const session = socket.deserializeAttachment() + yield* Effect.logInfo("Bot gateway websocket closed", { + sessionId: session?.sessionId, + botId: session?.botId, + code, + reason, + }) + // An unacknowledged batch is not lost: the bot resumes from its last ACK. + closeSocket(socket.ws as unknown as RawSocket, 1000, "closed") + }) + + const alarm = () => + Effect.gen(function* () { + const now = Date.now() + for (const { socket, session } of liveSessions()) { + if (session.pendingDeadline !== null && session.pendingDeadline <= now) { + const reason = `Timed out waiting for ACK from session ${session.sessionId}` + yield* Effect.logWarning("Bot gateway ACK timed out", { + sessionId: session.sessionId, + botId: session.botId, + }) + sendFrame(socket, { + op: "RECONNECT", + reason, + } satisfies typeof BotGatewayReconnectFrame.Type) + closeSocket(socket, 1012, reason) + } + } + Store.enforceRetention(sql, now, DEFAULT_RETENTION) + yield* scheduleAlarm + }) + + return { publish, fetch, webSocketMessage, webSocketClose, alarm } + }) +}) + +/** The implementation, as the layer the host Worker provides. */ +export const BotGatewayObjectLive = BotGatewayObject.make(activateBotGateway) diff --git a/apps/bot-gateway/src/gateway/relay.test.ts b/apps/bot-gateway/src/gateway/relay.test.ts new file mode 100644 index 000000000..d44d4f993 --- /dev/null +++ b/apps/bot-gateway/src/gateway/relay.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vitest" +import { decodeHandshake, encodeHandshake, type Handshake } from "./handshake.ts" +import { SESSION_CONFLICT_REASON } from "./log.ts" +import { + type AuthResult, + type RelayClock, + type RelaySocket, + relayCloseCode, + startRelay, + type UpstreamResult, +} from "./relay.ts" + +class FakeSocket implements RelaySocket { + readonly sent: Array = [] + closed: { code: number; reason: string } | undefined + private messageListeners: Array<(data: string) => void> = [] + private closeListeners: Array<(code: number, reason: string) => void> = [] + + send(data: string) { + this.sent.push(data) + } + close(code: number, reason: string) { + this.closed ??= { code, reason } + } + onMessage(listener: (data: string) => void) { + this.messageListeners.push(listener) + } + onClose(listener: (code: number, reason: string) => void) { + this.closeListeners.push(listener) + } + /** The peer sent `frame`. */ + receive(frame: object | string) { + const data = typeof frame === "string" ? frame : JSON.stringify(frame) + for (const listener of this.messageListeners) listener(data) + } + /** The peer closed. */ + peerClose(code = 1000, reason = "") { + for (const listener of this.closeListeners) listener(code, reason) + } + frames() { + return this.sent.map((data) => JSON.parse(data) as { op: string } & Record) + } +} + +const fakeClock = () => { + let now = 0 + const intervals: Array<() => void> = [] + const clock: RelayClock = { + now: () => now, + setInterval: (fn) => intervals.push(fn), + clearInterval: () => undefined, + } + return { + clock, + advance: (ms: number) => { + now += ms + for (const fn of intervals) fn() + }, + } +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +const BOT = { _tag: "Authenticated", botId: "00000000-0000-4000-8000-000000000111", botName: "echo" } as const + +const setup = (options?: { + auth?: (token: string) => Promise + connect?: (handshake: Handshake) => Promise +}) => { + const client = new FakeSocket() + const upstream = new FakeSocket() + const handshakes: Array = [] + const { clock, advance } = fakeClock() + const relay = startRelay({ + client, + heartbeatIntervalMs: 25_000, + leaseTtlMs: 75_000, + clock, + authenticate: options?.auth ?? (async () => BOT), + connect: async (handshake) => { + handshakes.push(handshake) + return options?.connect ? options.connect(handshake) : { _tag: "Connected", socket: upstream } + }, + }) + return { client, upstream, handshakes, relay, advance } +} + +describe("gateway relay", () => { + it("greets with HELLO and answers heartbeats itself, before and after identify", async () => { + const { client, upstream } = setup() + expect(client.frames()[0]).toEqual({ op: "HELLO", heartbeatIntervalMs: 25_000 }) + + client.receive({ op: "HEARTBEAT" }) + expect(client.frames()[1]).toEqual({ op: "HEARTBEAT_ACK" }) + + client.receive({ op: "IDENTIFY", botToken: "hzl_bot_x", resumeOffset: "now" }) + await flush() + client.receive({ op: "HEARTBEAT", sessionId: "s1" }) + expect(client.frames().at(-1)).toEqual({ op: "HEARTBEAT_ACK", sessionId: "s1" }) + expect(upstream.sent).toEqual([]) + }) + + it("authenticates IDENTIFY, opens the bot's session and relays both ways", async () => { + const tokens: Array = [] + const { client, upstream, handshakes, relay } = setup({ + auth: async (token) => { + tokens.push(token) + return BOT + }, + }) + client.receive({ op: "IDENTIFY", botToken: "hzl_bot_x", resumeOffset: "-1" }) + await flush() + + expect(tokens).toEqual(["hzl_bot_x"]) + expect(handshakes).toEqual([ + { botId: BOT.botId, botName: "echo", request: { op: "IDENTIFY", resumeOffset: "-1" } }, + ]) + expect(relay.phase()).toBe("relaying") + + const ready = JSON.stringify({ op: "READY", sessionId: "s1", resumed: false, resumeOffset: "-1" }) + upstream.receive(ready) + expect(client.sent.at(-1)).toBe(ready) + + const ack = { op: "ACK", sessionId: "s1", nextOffset: "0000000000000002" } + client.receive(ack) + expect(upstream.frames()).toEqual([ack]) + }) + + it("passes RESUME's session id and offset through", async () => { + const { client, handshakes } = setup() + client.receive({ op: "RESUME", botToken: "t", sessionId: "s9", resumeOffset: "0000000000000007" }) + await flush() + expect(handshakes[0]?.request).toEqual({ + op: "RESUME", + sessionId: "s9", + resumeOffset: "0000000000000007", + }) + }) + + it("rejects an invalid token with INVALID_SESSION", async () => { + const { client, handshakes } = setup({ + auth: async () => ({ _tag: "Rejected", reason: "Invalid bot token" }), + }) + client.receive({ op: "IDENTIFY", botToken: "nope", resumeOffset: "now" }) + await flush() + expect(client.frames().at(-1)).toEqual({ op: "INVALID_SESSION", reason: "Invalid bot token" }) + expect(client.closed?.code).toBe(1008) + expect(handshakes).toEqual([]) + }) + + it("relays the object's session-conflict rejection", async () => { + const { client } = setup({ + connect: async () => ({ _tag: "Rejected", reason: SESSION_CONFLICT_REASON }), + }) + client.receive({ op: "IDENTIFY", botToken: "t", resumeOffset: "now" }) + await flush() + expect(client.frames().at(-1)).toEqual({ op: "INVALID_SESSION", reason: SESSION_CONFLICT_REASON }) + expect(client.closed?.code).toBe(1008) + }) + + it("asks the bot to reconnect when the database or object is unavailable", async () => { + const down = setup({ auth: async () => ({ _tag: "Unavailable", reason: "db down" }) }) + down.client.receive({ op: "IDENTIFY", botToken: "t", resumeOffset: "now" }) + await flush() + expect(down.client.frames().at(-1)).toEqual({ op: "RECONNECT", reason: "gateway_unavailable" }) + expect(down.client.closed?.code).toBe(1012) + + const thrown = setup({ + connect: async () => { + throw new Error("boom") + }, + }) + thrown.client.receive({ op: "IDENTIFY", botToken: "t", resumeOffset: "now" }) + await flush() + expect(thrown.client.frames().at(-1)?.op).toBe("RECONNECT") + }) + + it("rejects undecodable frames like the Bun gateway did", () => { + const { client } = setup() + client.receive("not json") + expect(client.frames().at(-1)?.op).toBe("INVALID_SESSION") + expect(client.closed?.code).toBe(1008) + }) + + it("refuses a second IDENTIFY on an identified connection", async () => { + const { client, upstream } = setup() + client.receive({ op: "IDENTIFY", botToken: "t", resumeOffset: "now" }) + await flush() + client.receive({ op: "IDENTIFY", botToken: "t", resumeOffset: "now" }) + expect(client.frames().at(-1)).toEqual({ + op: "INVALID_SESSION", + reason: "session already identified", + }) + expect(upstream.closed?.code).toBe(1008) + }) + + it("propagates closes in both directions", async () => { + const a = setup() + a.client.receive({ op: "IDENTIFY", botToken: "t", resumeOffset: "now" }) + await flush() + a.upstream.peerClose(1012, "Timed out waiting for ACK") + expect(a.client.closed).toEqual({ code: 1012, reason: "Timed out waiting for ACK" }) + + const b = setup() + b.client.receive({ op: "IDENTIFY", botToken: "t", resumeOffset: "now" }) + await flush() + b.client.peerClose(1006) + expect(b.upstream.closed?.code).toBe(1000) + expect(b.relay.phase()).toBe("closed") + }) + + it("closes an upstream that connects after the client left", async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const upstream = new FakeSocket() + const { client } = setup({ + connect: async () => { + await gate + return { _tag: "Connected", socket: upstream } + }, + }) + client.receive({ op: "IDENTIFY", botToken: "t", resumeOffset: "now" }) + await flush() + client.peerClose() + release() + await flush() + expect(upstream.closed?.code).toBe(1000) + }) + + it("disconnects a client silent for the lease TTL, freeing the bot", async () => { + const { client, upstream, advance } = setup() + client.receive({ op: "IDENTIFY", botToken: "t", resumeOffset: "now" }) + await flush() + advance(50_000) + expect(client.closed).toBeUndefined() + client.receive({ op: "HEARTBEAT", sessionId: "s1" }) + advance(50_000) + expect(client.closed).toBeUndefined() + advance(30_000) + expect(client.closed).toEqual({ code: 1001, reason: "heartbeat_timeout" }) + expect(upstream.closed?.code).toBe(1001) + }) + + it("maps close codes a server may not send", () => { + expect(relayCloseCode(1000)).toBe(1000) + expect(relayCloseCode(1008)).toBe(1008) + expect(relayCloseCode(4001)).toBe(4001) + expect(relayCloseCode(1005)).toBe(1012) + expect(relayCloseCode(1006)).toBe(1012) + }) +}) + +describe("handshake headers", () => { + it("round-trips IDENTIFY and RESUME, including free-text bot names", () => { + const identify: Handshake = { + botId: BOT.botId, + botName: "Échø bot ✨", + request: { op: "IDENTIFY", resumeOffset: "now" }, + } + const resume: Handshake = { + botId: BOT.botId, + botName: "echo", + request: { op: "RESUME", sessionId: "s1", resumeOffset: "0000000000000003" }, + } + const lower = (headers: Record) => + Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])) + expect(decodeHandshake(lower(encodeHandshake(identify)))).toEqual(identify) + expect(decodeHandshake(lower(encodeHandshake(resume)))).toEqual(resume) + }) + + it("rejects incomplete handshakes", () => { + expect(decodeHandshake({})).toBeUndefined() + expect( + decodeHandshake({ + "x-hazel-bot-id": "b", + "x-hazel-bot-name": "n", + "x-hazel-gateway-op": "RESUME", + "x-hazel-gateway-resume-offset": "now", + }), + ).toBeUndefined() + }) +}) diff --git a/apps/bot-gateway/src/gateway/relay.ts b/apps/bot-gateway/src/gateway/relay.ts new file mode 100644 index 000000000..eca16199b --- /dev/null +++ b/apps/bot-gateway/src/gateway/relay.ts @@ -0,0 +1,238 @@ +/** + * The gateway Worker's side of a bot connection. The protocol puts the bot token in the first + * frame, so the bot's `BotGateway` object is unknown until that frame is authenticated: the + * Worker therefore terminates the bot's WebSocket itself and relays frames to a second WebSocket + * it opens to the object once the bot is known. + * + * The Worker also answers HEARTBEAT frames itself, so a quiet bot never wakes its (hibernated) + * object, and closes a client that stays silent for the lease TTL. That close is what frees the + * bot for a new session, the role the Redis lease TTL used to play. + * + * Plain callbacks over a minimal socket interface, so the state machine tests without workerd. + */ +import { + BotGatewayClientFrame, + type BotGatewayHeartbeatAckFrame, + type BotGatewayHelloFrame, + type BotGatewayInvalidSessionFrame, + type BotGatewayReconnectFrame, +} from "@hazel/domain" +import { Option, Schema } from "effect" +import type { Handshake } from "./handshake.ts" +import type { SessionRequest } from "./log.ts" + +export interface RelaySocket { + send(data: string): void + close(code: number, reason: string): void + onMessage(listener: (data: string) => void): void + onClose(listener: (code: number, reason: string) => void): void +} + +export type AuthResult = + | { readonly _tag: "Authenticated"; readonly botId: string; readonly botName: string } + | { readonly _tag: "Rejected"; readonly reason: string } + | { readonly _tag: "Unavailable"; readonly reason: string } + +export type UpstreamResult = + | { readonly _tag: "Connected"; readonly socket: RelaySocket } + | { readonly _tag: "Rejected"; readonly reason: string } + | { readonly _tag: "Unavailable"; readonly reason: string } + +export interface RelayClock { + readonly now: () => number + readonly setInterval: (fn: () => void, ms: number) => unknown + readonly clearInterval: (handle: unknown) => void +} + +const systemClock: RelayClock = { + now: () => Date.now(), + setInterval: (fn, ms) => setInterval(fn, ms), + clearInterval: (handle) => clearInterval(handle as ReturnType), +} + +export interface RelayOptions { + readonly client: RelaySocket + readonly heartbeatIntervalMs: number + readonly leaseTtlMs: number + readonly authenticate: (botToken: string) => Promise + readonly connect: (handshake: Handshake) => Promise + readonly clock?: RelayClock + readonly log?: (message: string, fields?: Record) => void +} + +export type RelayPhase = "awaiting_identify" | "authenticating" | "relaying" | "closed" + +export interface RelayHandle { + readonly phase: () => RelayPhase +} + +/** Codes a server may send in a close frame; anything else (1005, 1006, ...) becomes 1012. */ +export const relayCloseCode = (code: number): number => + code === 1000 || + (code >= 1001 && code <= 1003) || + (code >= 1007 && code <= 1014) || + (code >= 3000 && code <= 4999) + ? code + : 1012 + +const decodeClientFrame = Schema.decodeUnknownOption(BotGatewayClientFrame) + +const parseClientFrame = (data: string): BotGatewayClientFrame | undefined => { + try { + return Option.getOrUndefined(decodeClientFrame(JSON.parse(data))) + } catch { + return undefined + } +} + +const hello = (heartbeatIntervalMs: number): typeof BotGatewayHelloFrame.Type => ({ + op: "HELLO", + heartbeatIntervalMs, +}) + +export const startRelay = (options: RelayOptions): RelayHandle => { + const { client } = options + const clock = options.clock ?? systemClock + const log = options.log ?? (() => undefined) + + let phase: RelayPhase = "awaiting_identify" + let upstream: RelaySocket | undefined + let lastActivity = clock.now() + const buffered: Array = [] + // A function, so checks after an `await` are not narrowed away. + const isClosed = () => phase === "closed" + + const send = (frame: object) => { + try { + client.send(JSON.stringify(frame)) + } catch { + // The client is gone; its close listener finishes the teardown. + } + } + + const closeSocket = (socket: RelaySocket | undefined, code: number, reason: string) => { + try { + socket?.close(relayCloseCode(code), reason.slice(0, 120)) + } catch { + // Already closed. + } + } + + const shutdown = (code: number, reason: string) => { + if (phase === "closed") return + phase = "closed" + clock.clearInterval(watchdog) + closeSocket(upstream, code, reason) + closeSocket(client, code, reason) + } + + const invalidSession = (reason: string) => { + send({ op: "INVALID_SESSION", reason } satisfies typeof BotGatewayInvalidSessionFrame.Type) + shutdown(1008, reason) + } + + const reconnect = (reason: string) => { + send({ op: "RECONNECT", reason } satisfies typeof BotGatewayReconnectFrame.Type) + shutdown(1012, reason) + } + + const watchdog = clock.setInterval( + () => { + if (phase !== "closed" && clock.now() - lastActivity > options.leaseTtlMs) { + log("Bot gateway client heartbeat timed out", { leaseTtlMs: options.leaseTtlMs }) + shutdown(1001, "heartbeat_timeout") + } + }, + Math.max(1_000, Math.min(options.heartbeatIntervalMs, options.leaseTtlMs)), + ) + + const attachUpstream = (socket: RelaySocket) => { + upstream = socket + phase = "relaying" + socket.onMessage((data) => { + if (phase !== "closed") { + try { + client.send(data) + } catch { + // The client is gone; its close listener finishes the teardown. + } + } + }) + socket.onClose((code, reason) => shutdown(code, reason || "gateway_session_closed")) + for (const data of buffered.splice(0)) socket.send(data) + } + + const identify = async (botToken: string, request: SessionRequest) => { + const auth = await options + .authenticate(botToken) + .catch((cause): AuthResult => ({ _tag: "Unavailable", reason: `auth failed: ${String(cause)}` })) + if (isClosed()) return + if (auth._tag === "Rejected") return invalidSession(auth.reason) + if (auth._tag === "Unavailable") { + log("Bot gateway authentication unavailable", { reason: auth.reason }) + return reconnect("gateway_unavailable") + } + + const result = await options + .connect({ botId: auth.botId, botName: auth.botName, request }) + .catch((cause): UpstreamResult => ({ _tag: "Unavailable", reason: String(cause) })) + if (isClosed()) { + if (result._tag === "Connected") closeSocket(result.socket, 1000, "client_closed") + return + } + switch (result._tag) { + case "Connected": + return attachUpstream(result.socket) + case "Rejected": + return invalidSession(result.reason) + case "Unavailable": + log("Bot gateway session unavailable", { botId: auth.botId, reason: result.reason }) + return reconnect("gateway_unavailable") + } + } + + client.onMessage((data) => { + if (phase === "closed") return + lastActivity = clock.now() + const frame = parseClientFrame(data) + if (frame === undefined) return invalidSession("Failed to decode gateway frame") + + switch (frame.op) { + case "HEARTBEAT": { + send({ + op: "HEARTBEAT_ACK", + sessionId: frame.sessionId, + } satisfies typeof BotGatewayHeartbeatAckFrame.Type) + return + } + case "IDENTIFY": + case "RESUME": { + // A repeat while authenticating is dropped; after READY the session is already + // identified (the lease rejected a second identify the same way). + if (phase === "authenticating") return + if (phase === "relaying") return invalidSession("session already identified") + phase = "authenticating" + const request: SessionRequest = + frame.op === "IDENTIFY" + ? { op: "IDENTIFY", resumeOffset: frame.resumeOffset } + : { op: "RESUME", sessionId: frame.sessionId, resumeOffset: frame.resumeOffset } + void identify(frame.botToken, request) + return + } + case "ACK": { + // Nothing can be acknowledged before the session is READY. + if (phase === "awaiting_identify") return + break + } + } + // Post-identify frames belong to the bot's object. + if (phase === "relaying" && upstream !== undefined) upstream.send(data) + else buffered.push(data) + }) + + client.onClose(() => shutdown(1000, "client_closed")) + + send(hello(options.heartbeatIntervalMs)) + + return { phase: () => phase } +} diff --git a/apps/bot-gateway/src/gateway/settings.ts b/apps/bot-gateway/src/gateway/settings.ts new file mode 100644 index 000000000..da5df897f --- /dev/null +++ b/apps/bot-gateway/src/gateway/settings.ts @@ -0,0 +1,34 @@ +/** + * Gateway timing, from the same env keys (and defaults) the Bun gateway read. Parsed from the + * Worker env by hand rather than through `Config`: alchemy binds every `Config` read inside a + * Worker or Durable Object init as a secret at plan time. + */ +export const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000 +export const DEFAULT_LEASE_TTL_SECONDS = 75 +export const DEFAULT_BATCH_ACK_TIMEOUT_MS = 60_000 + +export const GATEWAY_ENV_KEYS = [ + "GATEWAY_HEARTBEAT_INTERVAL_MS", + "GATEWAY_LEASE_TTL_SECONDS", + "GATEWAY_BATCH_ACK_TIMEOUT_MS", +] as const + +export interface GatewaySettings { + /** Sent to bots in HELLO; the cadence of their HEARTBEAT frames. */ + readonly heartbeatIntervalMs: number + /** A client silent for this long is disconnected, freeing the bot for a new session. */ + readonly leaseTtlMs: number + /** How long a DISPATCH batch may wait for its ACK before the session is told to reconnect. */ + readonly batchAckTimeoutMs: number +} + +const positiveInt = (value: unknown, fallback: number): number => { + const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value.trim()) : NaN + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback +} + +export const readGatewaySettings = (env: Record): GatewaySettings => ({ + heartbeatIntervalMs: positiveInt(env.GATEWAY_HEARTBEAT_INTERVAL_MS, DEFAULT_HEARTBEAT_INTERVAL_MS), + leaseTtlMs: positiveInt(env.GATEWAY_LEASE_TTL_SECONDS, DEFAULT_LEASE_TTL_SECONDS) * 1000, + batchAckTimeoutMs: positiveInt(env.GATEWAY_BATCH_ACK_TIMEOUT_MS, DEFAULT_BATCH_ACK_TIMEOUT_MS), +}) diff --git a/apps/bot-gateway/src/gateway/workerd.ts b/apps/bot-gateway/src/gateway/workerd.ts new file mode 100644 index 000000000..5497bfe15 --- /dev/null +++ b/apps/bot-gateway/src/gateway/workerd.ts @@ -0,0 +1,96 @@ +/** + * The few workerd WebSocket APIs the gateway Worker uses, typed locally so this package keeps + * compiling under Bun's ambient types (the Bun gateway in `src/index.ts` shares the tsconfig). + */ +import type { Handshake } from "./handshake.ts" +import { encodeHandshake } from "./handshake.ts" +import type { RelaySocket, UpstreamResult } from "./relay.ts" + +export interface WorkerdWebSocket { + accept(): void + send(data: string): void + close(code?: number, reason?: string): void + addEventListener(type: "message", listener: (event: { readonly data: unknown }) => void): void + addEventListener( + type: "close", + listener: (event: { readonly code: number; readonly reason: string }) => void, + ): void + addEventListener(type: "error", listener: (event: unknown) => void): void +} + +interface WorkerdResponse { + readonly status: number + readonly webSocket?: WorkerdWebSocket | null + text(): Promise +} + +/** A Durable Object namespace binding, as far as the relay uses it. */ +export interface WorkerdNamespace { + getByName(name: string): { + fetch(input: string, init: { readonly headers: Record }): Promise + } +} + +/** `new WebSocketPair()`: `[client, server]`. */ +export const newWebSocketPair = (): readonly [WorkerdWebSocket, WorkerdWebSocket] => { + const Pair = (globalThis as unknown as { WebSocketPair: new () => Record<0 | 1, WorkerdWebSocket> }) + .WebSocketPair + const pair = new Pair() + return [pair[0], pair[1]] +} + +/** The 101 response that hands `client` to the caller. */ +export const upgradeResponse = (client: WorkerdWebSocket): Response => + new Response(null, { status: 101, webSocket: client } as ResponseInit) + +const textOf = (data: unknown): string => + typeof data === "string" + ? data + : data instanceof ArrayBuffer + ? new TextDecoder().decode(data) + : ArrayBuffer.isView(data) + ? new TextDecoder().decode(data) + : String(data) + +/** Adapt an accepted workerd socket to the relay's interface. */ +export const relaySocket = (ws: WorkerdWebSocket): RelaySocket => ({ + send: (data) => ws.send(data), + close: (code, reason) => ws.close(code, reason), + onMessage: (listener) => ws.addEventListener("message", (event) => listener(textOf(event.data))), + onClose: (listener) => { + ws.addEventListener("close", (event) => listener(event.code, event.reason)) + ws.addEventListener("error", () => listener(1011, "websocket_error")) + }, +}) + +/** Open the bot's session on its `BotGateway` object. */ +export const connectToObject = async ( + namespace: WorkerdNamespace, + handshake: Handshake, +): Promise => { + const response = await namespace + .getByName(handshake.botId) + .fetch("https://bot-gateway.internal/session", { + headers: { Upgrade: "websocket", ...encodeHandshake(handshake) }, + }) + if (response.status === 101 && response.webSocket) { + response.webSocket.accept() + return { _tag: "Connected", socket: relaySocket(response.webSocket) } + } + const body = await response.text().catch(() => "") + if (response.status === 409) { + const reason = (() => { + try { + const parsed = JSON.parse(body) as { reason?: unknown } + return typeof parsed.reason === "string" ? parsed.reason : undefined + } catch { + return undefined + } + })() + return { _tag: "Rejected", reason: reason ?? "session rejected" } + } + return { + _tag: "Unavailable", + reason: `BotGateway object answered ${response.status}: ${body.slice(0, 200)}`, + } +} diff --git a/apps/bot-gateway/src/object.ts b/apps/bot-gateway/src/object.ts new file mode 100644 index 000000000..19a000d02 --- /dev/null +++ b/apps/bot-gateway/src/object.ts @@ -0,0 +1,43 @@ +/** + * The `BotGateway` Durable Object as alchemy binds it: one object per bot (`getByName(botId)`), + * SQLite-backed, hosted by the bot-gateway Worker (`src/worker.ts`, implementation in + * `src/gateway/object-live.ts`). Light on purpose: the backend imports this module to bind the + * namespace cross-script, and the implementation's `.make` is tree-shaken out of its bundle. + */ +import { BotGatewayEventRejectedError, type BotGatewayRpc } from "@hazel/domain" +import { parseHazelStageEffect, resolveWorkerName } from "@hazel/infra/cloudflare" +import * as Cloudflare from "alchemy/Cloudflare" +import { Stage } from "alchemy/Stage" +import { Effect } from "effect" + +/** The namespace's name: also its binding name on the host Worker's env and its class name. */ +export const BOT_GATEWAY_NAMESPACE = "BotGateway" + +export class BotGatewayObject extends Cloudflare.DurableObject()( + BOT_GATEWAY_NAMESPACE, + { + errors: [BotGatewayEventRejectedError], + }, +) {} + +/** The Worker that hosts the class; also its name base (`hazel-bot-gateway` in prd). */ +export const BOT_GATEWAY_APP = "bot-gateway" + +/** + * The bot-gateway Worker's script name for a cross-script binding. Computed from the stage, not + * read off the Worker resource, so a binder's deploy does not wait on the gateway's. Unread in + * the isolate, where the binding is already on the env. + */ +const botGatewayScriptName = Effect.gen(function* () { + if (globalThis.__ALCHEMY_RUNTIME__) return "" + return resolveWorkerName(BOT_GATEWAY_APP, yield* parseHazelStageEffect(yield* Stage)) + // The root stack parses the same stage first and fails typed there. +}).pipe(Effect.orDie) + +/** + * Bind the gateway's `BotGateway` namespace from another Worker's init (the backend): + * `const gateways = yield* bindBotGateways`, then `gateways.getByName(botId).publish(json)`. + */ +export const bindBotGateways = Effect.flatMap(botGatewayScriptName, (scriptName) => + BotGatewayObject.from(scriptName), +) diff --git a/apps/bot-gateway/src/worker.ts b/apps/bot-gateway/src/worker.ts new file mode 100644 index 000000000..6c5fd0ef3 --- /dev/null +++ b/apps/bot-gateway/src/worker.ts @@ -0,0 +1,119 @@ +/** + * The bot gateway on Cloudflare (alchemy single-module form): this Worker serves + * `/bot-gateway/ws` and hosts the `BotGateway` Durable Object (one per bot). The backend binds + * the object cross-script (`bindBotGateways` in `./object.ts`) to publish events. + * + * A bot connection: the Worker accepts the socket and sends HELLO, authenticates the bot token in + * the first IDENTIFY/RESUME frame against Postgres (Hyperdrive), then relays the session to the + * bot's object (`gateway/relay.ts`). The wire protocol is the Bun gateway's, unchanged. + */ +import { HAZEL_DB_BINDING, HazelStack, hazelWorkerProps, readHazelDbBinding } from "@hazel/infra/cloudflare" +import { merge, optionalPlain } from "@hazel/infra/env" +import * as Cloudflare from "alchemy/Cloudflare" +import { Effect, Option } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/http" +import * as HttpBody from "effect/http/HttpBody" +import { authenticateBotToken } from "./gateway/auth.ts" +import { BotGatewayObjectLive } from "./gateway/object-live.ts" +import { startRelay } from "./gateway/relay.ts" +import { GATEWAY_ENV_KEYS, readGatewaySettings } from "./gateway/settings.ts" +import { + connectToObject, + newWebSocketPair, + relaySocket, + upgradeResponse, + type WorkerdNamespace, +} from "./gateway/workerd.ts" +import { BOT_GATEWAY_APP, BOT_GATEWAY_NAMESPACE, BotGatewayObject } from "./object.ts" + +export const BOT_GATEWAY_WS_PATH = "/bot-gateway/ws" + +/** `__ALCHEMY_RUNTIME__` folds to `true` in the bundle, so the stack-side branch is tree-shaken. */ +const props = Effect.gen(function* () { + if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } + const stack = yield* HazelStack + // The Bun gateway's tuning knobs; unset keeps its defaults (gateway/settings.ts). + const env = yield* merge(...GATEWAY_ENV_KEYS.map((key) => optionalPlain(key))) + return { + main: import.meta.url, + ...hazelWorkerProps(BOT_GATEWAY_APP, stack), + workersDev: stack.stage.kind !== "prd", + domain: stack.domains.botGateway, + env: { [HAZEL_DB_BINDING]: stack.db.hyperdrive, ...env }, + observability: { + enabled: true, + headSamplingRate: 1, + logs: { + enabled: true, + headSamplingRate: 1, + persist: true, + invocationLogs: true, + destinations: ["maple-logs"], + }, + traces: { enabled: true, persist: true, headSamplingRate: 1, destinations: ["maple-traces"] }, + }, + } +}) + +/** `BotGatewayObject` in the contract is what lets the backend bind it with `.from(...)`. */ +export class BotGatewayWorker extends Cloudflare.Worker< + BotGatewayWorker, + Cloudflare.WorkerShape, + BotGatewayObject +>()(BOT_GATEWAY_APP) {} + +const pathOf = (url: string): string => { + const path = url.startsWith("/") ? url : new URL(url).pathname + const query = path.indexOf("?") + return query === -1 ? path : path.slice(0, query) +} + +export default BotGatewayWorker.make( + props, + Effect.gen(function* () { + // Yielding the hosted DO binds, registers and exports it. + yield* BotGatewayObject + const env = yield* Cloudflare.WorkerEnvironment + const settings = readGatewaySettings(env) + + const openSession = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + if (request.headers.upgrade?.toLowerCase() !== "websocket") { + return HttpServerResponse.text("Expected a WebSocket upgrade", { status: 426 }) + } + // Valid for this invocation, which lives as long as the socket. + const binding = readHazelDbBinding(env) + if (Option.isNone(binding)) { + return HttpServerResponse.text("HAZEL_DB binding is missing", { status: 500 }) + } + const connectionString = binding.value.connectionString + const namespace = env[BOT_GATEWAY_NAMESPACE] as WorkerdNamespace + + const [client, server] = newWebSocketPair() + server.accept() + startRelay({ + client: relaySocket(server), + heartbeatIntervalMs: settings.heartbeatIntervalMs, + leaseTtlMs: settings.leaseTtlMs, + authenticate: (botToken) => + Effect.runPromise(authenticateBotToken(botToken, connectionString)), + connect: (handshake) => connectToObject(namespace, handshake), + log: (message, fields) => console.warn(message, fields ?? {}), + }) + return HttpServerResponse.setBody( + HttpServerResponse.empty({ status: 101 }), + HttpBody.raw(upgradeResponse(client)), + ) + }).pipe(Effect.withSpan("BotGatewayWorker.websocketUpgrade")) + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const path = pathOf(request.url) + if (request.method === "GET" && path === "/health") return HttpServerResponse.text("OK") + if (request.method === "GET" && path === BOT_GATEWAY_WS_PATH) return yield* openSession + return HttpServerResponse.text("Not found", { status: 404 }) + }), + } + }).pipe(Effect.provide(BotGatewayObjectLive)), +) diff --git a/apps/bot-gateway/vitest.config.ts b/apps/bot-gateway/vitest.config.ts new file mode 100644 index 000000000..1b4844de8 --- /dev/null +++ b/apps/bot-gateway/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + // The Bun gateway's tests use `bun:test`; `bun test src/index.test.ts` runs them. + exclude: ["src/index.test.ts"], + }, +}) diff --git a/apps/cluster/src/index.ts b/apps/cluster/src/index.ts index 642a35806..ab0953d4b 100644 --- a/apps/cluster/src/index.ts +++ b/apps/cluster/src/index.ts @@ -12,6 +12,7 @@ import { PresenceCleanupCronLayer } from "./cron/presence-cleanup-cron.ts" import { StatusExpirationCronLayer } from "./cron/status-expiration-cron.ts" import { TypingIndicatorCleanupCronLayer } from "./cron/typing-indicator-cleanup-cron.ts" import { UploadCleanupCronLayer } from "./cron/upload-cleanup-cron.ts" +import { requireApiSecret } from "./services/api-secret.ts" import { BotUserServiceLive } from "./services/bot-user-service.ts" import { OpenRouterLanguageModelLayer } from "./services/openrouter-service.ts" import { RssPollCronLayer } from "./cron/rss-poll-cron.ts" @@ -83,7 +84,7 @@ const AllRoutes = Layer.mergeAll(WorkflowApiLive).pipe( ) // Main server layer -const ServerLayer = HttpRouter.serve(AllRoutes).pipe( +const ServerLayer = HttpRouter.serve(AllRoutes, { middleware: requireApiSecret }).pipe( Layer.provide(AllWorkflows), Layer.provide(AllCronJobs), Layer.provide(Logger.layer([Logger.consolePretty()])), diff --git a/apps/cluster/src/services/api-secret.ts b/apps/cluster/src/services/api-secret.ts new file mode 100644 index 000000000..81aa28a88 --- /dev/null +++ b/apps/cluster/src/services/api-secret.ts @@ -0,0 +1,31 @@ +import { timingSafeEqual } from "node:crypto" +import { Cluster } from "@hazel/domain" +import { Config, Effect, Option, Redacted } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/http" + +const matches = (expected: string, received: string): boolean => { + const a = Buffer.from(expected) + const b = Buffer.from(received) + return a.length === b.length && timingSafeEqual(a, b) +} + +/** + * Server middleware rejecting calls without the shared `CLUSTER_API_SECRET`, sent by the backend + * (now on Cloudflare, so it reaches the cluster over the public internet) in + * `Cluster.CLUSTER_API_SECRET_HEADER`. `/health` stays open for Railway's health checks. With + * no secret configured every call passes, which keeps local dev and the cutover window working. + */ +export const requireApiSecret = ( + app: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const secret = yield* Config.option(Config.Redacted("CLUSTER_API_SECRET")).pipe(Effect.orDie) + if (Option.isNone(secret)) return yield* app + const request = yield* HttpServerRequest.HttpServerRequest + if (new URL(request.url, "http://cluster").pathname === "/health") return yield* app + const received = request.headers[Cluster.CLUSTER_API_SECRET_HEADER] + if (received === undefined || !matches(Redacted.value(secret.value), received)) { + return HttpServerResponse.text("Unauthorized", { status: 401 }) + } + return yield* app + }) diff --git a/apps/docs/alchemy.run.ts b/apps/docs/alchemy.run.ts new file mode 100644 index 000000000..50bd05522 --- /dev/null +++ b/apps/docs/alchemy.run.ts @@ -0,0 +1,21 @@ +/** + * The docs site (TanStack Start + fumadocs). `Website.Vite` injects the Cloudflare Vite plugin, + * which builds the SSR Worker and client assets in one `vite build`. + */ +import { HazelStack, resolveWorkerName } from "@hazel/infra/cloudflare" +import * as Cloudflare from "alchemy/Cloudflare" +import { Effect } from "effect" + +const props = Effect.gen(function* () { + const { stage, domains } = yield* HazelStack + return { + name: resolveWorkerName("docs", stage), + rootDir: new URL(".", import.meta.url).pathname, + memo: { include: ["**/*", "../../packages/ui/src/**"], lockfile: true }, + compatibility: { date: "2026-10-01" }, + workersDev: stage.kind !== "prd", + domain: domains.docs, + } +}) + +export default class Docs extends Cloudflare.Website.Vite()("docs", props) {} diff --git a/apps/docs/package.json b/apps/docs/package.json index 5dd2ae46f..aeae8b7cd 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -19,7 +19,6 @@ "fumadocs-mdx": "14.2.6", "fumadocs-ui": "16.5.0", "lucide-react": "^0.577.0", - "nitro": "^3.0.1-alpha.2", "react": "19.2.4", "react-dom": "19.2.4", "tailwind-merge": "^3.5.0", diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts index 2952d0b38..8c7ffef6c 100644 --- a/apps/docs/vite.config.ts +++ b/apps/docs/vite.config.ts @@ -5,8 +5,6 @@ import tsConfigPaths from "vite-tsconfig-paths" import tailwindcss from "@tailwindcss/vite" import mdx from "fumadocs-mdx/vite" -import { nitro } from "nitro/vite" - export default defineConfig({ server: { port: 3000, @@ -22,7 +20,6 @@ export default defineConfig({ // enabled: true, // }, }), - nitro(), react(), ], }) diff --git a/apps/electric-proxy/.env.example b/apps/electric-proxy/.env.example index ca9e560c3..17e8177d7 100644 --- a/apps/electric-proxy/.env.example +++ b/apps/electric-proxy/.env.example @@ -7,8 +7,11 @@ IS_DEV=true # Electric SQL ELECTRIC_URL=http://localhost:3333 -ELECTRIC_SOURCE_ID=optional-electric-cloud-source-id -ELECTRIC_SOURCE_SECRET=optional-electric-cloud-secret +# Self-hosted Electric's ELECTRIC_SECRET (unset for docker-compose Electric, which runs insecure) +# ELECTRIC_SECRET= +# Legacy Electric Cloud credentials (Bun entry on Railway only; removed at the Cloudflare cutover) +# ELECTRIC_SOURCE_ID= +# ELECTRIC_SOURCE_SECRET= # Clerk Authentication (validates Bearer JWTs) CLERK_SECRET_KEY=sk_test_your_clerk_secret_here diff --git a/apps/electric-proxy/alchemy.run.ts b/apps/electric-proxy/alchemy.run.ts new file mode 100644 index 000000000..135bcfe96 --- /dev/null +++ b/apps/electric-proxy/alchemy.run.ts @@ -0,0 +1,7 @@ +/** + * electric-proxy for the root stack: the Effect Worker (`src/worker.ts`) plus, on deployed + * non-PR stages, the `electric` Worker running self-hosted Electric in a Container + * (`resources.ts`), which the proxy's props yield. The root needs only `yield* ElectricProxy`. + */ +export { default } from "./src/worker.ts" +export { ELECTRIC_IMAGE, ElectricHost, ProxyCache, usesElectricContainer } from "./resources.ts" diff --git a/apps/electric-proxy/package.json b/apps/electric-proxy/package.json index 94d480ebb..412d45a05 100644 --- a/apps/electric-proxy/package.json +++ b/apps/electric-proxy/package.json @@ -9,16 +9,21 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@cloudflare/containers": "0.3.7", "@effect/platform-bun": "catalog:effect", "@electric-sql/client": "1.5.15", "@hazel/auth": "workspace:*", "@hazel/db": "workspace:*", "@hazel/effect-bun": "workspace:*", + "@hazel/effect-cloudflare": "workspace:*", + "@hazel/infra": "workspace:*", "@hazel/schema": "workspace:*", + "alchemy": "catalog:alchemy", "drizzle-orm": "^0.45.1", "effect": "catalog:effect" }, "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", "@types/bun": "1.3.9", "typescript": "^5.9.3" } diff --git a/apps/electric-proxy/resources.ts b/apps/electric-proxy/resources.ts new file mode 100644 index 000000000..115a1a6cd --- /dev/null +++ b/apps/electric-proxy/resources.ts @@ -0,0 +1,105 @@ +/** + * electric-proxy's resources, plan-side: its KV cache and self-hosted ElectricSQL on Cloudflare + * Containers. The `electric` Worker hosts the container-backed Durable Object + * (`src/electric-container.ts`); electric-proxy binds that namespace cross-script as `ELECTRIC`. + * Imported only from `src/worker.ts`'s props, whose branch is tree-shaken from the bundle. + */ +import { + type HazelStackContext, + HazelStack, + hazelWorkerProps, + resolveWorkerName, + stageProps, +} from "@hazel/infra/cloudflare" +import { merge, optionalPlain, optionalSecret, requireSecretEntry } from "@hazel/infra/env" +import * as Cloudflare from "alchemy/Cloudflare" +import { Effect } from "effect" +import type { ElectricContainer } from "./src/electric-container.ts" + +/** + * Pinned, never `latest`: the storage format changes across minors. `electric-temp` because + * Docker Hub's `electricsql/electric` denies pulls (see docker-compose.yaml); switch back once it + * is restored. Alchemy re-pushes the image to the account registry, so a bump is a deploy step. + */ +export const ELECTRIC_IMAGE = "docker.io/electricsql/electric-temp:1.8.1" + +/** Backs the bot access-context and Clerk user-lookup caches (Redis on the Bun entry). */ +export const ProxyCache = Cloudflare.KV.Namespace( + "electric-proxy-cache", + stageProps("electric-proxy-cache", (title) => ({ title })), +) + +/** The exported Durable Object class in `src/electric-container.ts`. */ +const ELECTRIC_CLASS_NAME = "ElectricContainer" + +/** Workers observability → Maple, as for actors (no OTel SDK in these Workers). */ +export const mapleObservability = { + enabled: true, + headSamplingRate: 1, + logs: { + enabled: true, + headSamplingRate: 1, + persist: true, + invocationLogs: true, + destinations: ["maple-logs"], + }, + traces: { enabled: true, persist: true, headSamplingRate: 1, destinations: ["maple-traces"] }, +} + +/** + * The `electric` Worker and its container. Reached only over the cross-script DO binding: no + * route, no workers.dev. `ELECTRIC_DATABASE_URL` is a direct (non-Hyperdrive) Postgres URL whose + * role has REPLICATION. + */ +export const ElectricHost = Effect.gen(function* () { + const stack = yield* HazelStack + return yield* Cloudflare.Worker("electric", { + ...hazelWorkerProps("electric", stack), + main: new URL("./src/electric-container.ts", import.meta.url).pathname, + workersDev: false, + observability: mapleObservability, + env: { + [ELECTRIC_CLASS_NAME]: Cloudflare.Container(ELECTRIC_CLASS_NAME, { + name: resolveWorkerName("electric", stack.stage), + image: ELECTRIC_IMAGE, + // 1 vCPU / 6 GiB / 12 GB disk: room for the shape log of every synced table. + instanceType: "standard-2", + // Exactly one: two instances cannot share the replication slot. + maxInstances: 1, + observability: { logs: { enabled: true } }, + }), + // prd: the stack's PlanetScale replication role; otherwise a URL given to the deploy. + ...(stack.db.electricRole + ? { ELECTRIC_DATABASE_URL: stack.db.electricRole.connectionUrl } + : yield* requireSecretEntry("ELECTRIC_DATABASE_URL")), + ...(yield* requireSecretEntry("ELECTRIC_SECRET")), + }, + }) +}) + +/** + * Whether this deploy runs its own Electric container: not under `alchemy dev` (docker-compose + * Electric via `ELECTRIC_URL`), and not for PR previews (no Electric; shape requests answer 503). + */ +export const usesElectricContainer = ({ stage, isDevServer }: HazelStackContext): boolean => + !isDevServer && stage.kind !== "pr" + +/** electric-proxy's Electric env: the container's namespace, or the `ELECTRIC_URL` fallback. */ +export const electricProxyEnv = (stack: HazelStackContext) => + Effect.gen(function* () { + if (usesElectricContainer(stack)) { + const host = yield* ElectricHost + return { + ELECTRIC: Cloudflare.DurableObject("ELECTRIC", { + className: ELECTRIC_CLASS_NAME, + scriptName: host.workerName, + }), + ...(yield* requireSecretEntry("ELECTRIC_SECRET")), + } + } + return yield* merge( + // docker-compose maps Electric to 3333. + optionalPlain("ELECTRIC_URL", stack.isDevServer ? "http://localhost:3333" : undefined), + optionalSecret("ELECTRIC_SECRET"), + ) + }) diff --git a/apps/electric-proxy/src/auth/bot-auth.ts b/apps/electric-proxy/src/auth/bot-auth.ts index 1c925da15..7b136c5a8 100644 --- a/apps/electric-proxy/src/auth/bot-auth.ts +++ b/apps/electric-proxy/src/auth/bot-auth.ts @@ -93,7 +93,7 @@ export const validateBotToken = Effect.fn("ElectricProxy.validateBotToken")(func const bot = botOption.value yield* Effect.annotateCurrentSpan("auth.token.valid", true) - // Get cached access context from Redis-backed cache + // Get cached access context (persistence-backed cache) const cache = yield* AccessContextCacheService const accessContext = yield* cache.getBotContext(bot.id, bot.userId) yield* Effect.annotateCurrentSpan("proxy.bot.channel_count", accessContext.channelIds.length) diff --git a/apps/electric-proxy/src/cache/access-context-service.ts b/apps/electric-proxy/src/cache/access-context-service.ts index d63963ce3..499159bbc 100644 --- a/apps/electric-proxy/src/cache/access-context-service.ts +++ b/apps/electric-proxy/src/cache/access-context-service.ts @@ -30,7 +30,8 @@ export interface AccessContextCache { /** * Access context caching service. - * Uses PersistedCache to cache bot access contexts with Redis persistence. + * Uses PersistedCache to cache bot access contexts in the provided ResultPersistence + * (Redis on Bun, Workers KV on Cloudflare). * * Note: Database.Database is intentionally NOT included in dependencies * as it's a global infrastructure layer provided at the application root. @@ -100,7 +101,7 @@ export class AccessContextCacheService extends Context.Service | undefined + /** Electric Cloud (legacy Railway deployment only; the Worker talks to self-hosted Electric). */ readonly electricSourceId: string | undefined readonly electricSourceSecret: string | undefined readonly allowedOrigin: string @@ -16,12 +19,16 @@ export interface ProxyConfig { } /** - * Proxy configuration service. + * Bun entry configuration service. * Reads configuration from environment variables. */ export class ProxyConfigService extends Context.Service()("ProxyConfigService", { make: Effect.gen(function* () { const electricUrl = yield* Config.String("ELECTRIC_URL") + const electricSecret = yield* Config.Redacted("ELECTRIC_SECRET").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ) const electricSourceId = yield* Config.String("ELECTRIC_SOURCE_ID").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -46,6 +53,7 @@ export class ProxyConfigService extends Context.Service()("P return { electricUrl, + electricSecret, electricSourceId, electricSourceSecret, allowedOrigin, diff --git a/apps/electric-proxy/src/electric-container.ts b/apps/electric-proxy/src/electric-container.ts new file mode 100644 index 000000000..5a2a9e21f --- /dev/null +++ b/apps/electric-proxy/src/electric-container.ts @@ -0,0 +1,60 @@ +/** + * The `electric` Worker: hosts the container-backed Durable Object that runs self-hosted + * ElectricSQL. A plain async bundle entry (not an alchemy-generated one) so the third-party + * `Container` subclass is exported from the script. Only `electric-proxy` reaches it, through a + * cross-script Durable Object binding addressed by the fixed name `"electric"`. + */ +import { Container, type StopParams } from "@cloudflare/containers" + +/** Electric's HTTP port (`ELECTRIC_PORT`'s default). */ +const ELECTRIC_PORT = 3000 + +export interface ElectricHostEnv { + /** Direct Postgres URL with REPLICATION; never Hyperdrive, which cannot do logical replication. */ + readonly ELECTRIC_DATABASE_URL: string + /** Shared with electric-proxy, which sends it as the `secret` query param. */ + readonly ELECTRIC_SECRET: string +} + +/** + * One instance (`maxInstances: 1`, one object name): Electric owns a single replication slot, and + * a second instance would fight over it. It never sleeps, since a cold start re-snapshots every + * shape and an idle container stops consuming the slot while Postgres keeps WAL for it. + */ +export class ElectricContainer extends Container { + override defaultPort = ELECTRIC_PORT + // Effectively disabled; `onActivityExpired` below also refuses to stop. + override sleepAfter = "8760h" + // Electric dials Postgres over the public internet. + override enableInternet = true + + constructor(ctx: ConstructorParameters[0], env: ElectricHostEnv) { + super(ctx, env) + this.envVars = { + DATABASE_URL: env.ELECTRIC_DATABASE_URL, + ELECTRIC_SECRET: env.ELECTRIC_SECRET, + // Matches docker-compose: the proxy's where-clauses use subqueries. + ELECTRIC_FEATURE_FLAGS: "allow_subqueries,tagged_subqueries", + // Container-local disk: losing it costs only a re-snapshot (clients get `must-refetch`). + ELECTRIC_STORAGE_DIR: "/app/persistent", + ELECTRIC_PORT: String(ELECTRIC_PORT), + } + } + + /** Never sleep: keep the replication slot consumed between bursts of shape requests. */ + override async onActivityExpired(): Promise {} + + override onStop(params: StopParams): void { + // The next shape request restarts it (`containerFetch` starts a stopped container). + console.warn("Electric container stopped", params) + } + + override onError(error: unknown): unknown { + console.error("Electric container error", error) + throw error + } +} + +export default { + fetch: (): Response => new Response("Not Found", { status: 404 }), +} diff --git a/apps/electric-proxy/src/handler.ts b/apps/electric-proxy/src/handler.ts new file mode 100644 index 000000000..6921ad648 --- /dev/null +++ b/apps/electric-proxy/src/handler.ts @@ -0,0 +1,444 @@ +/** + * The proxy's request path, runtime-agnostic: the Bun entry (`index.ts`) and the Cloudflare + * Worker (`worker.ts`) both serve {@link handleRequest} and differ only in the services they provide. + */ +import { Effect, Metric } from "effect" +import { validateBotToken } from "./auth/bot-auth" +import { validateSession } from "./auth/user-auth" +import { proxyAuthFailures, proxyRequestDuration, proxyRequestsTotal } from "./observability/metrics" +import { type ElectricProxyError, prepareElectricUrl, proxyElectricRequest } from "./proxy/electric-client" +import { type BotTableAccessError, getBotWhereClauseForTable, validateBotTable } from "./tables/bot-tables" +import { getWhereClauseForTable, type TableAccessError, validateTable } from "./tables/user-tables" +import { applyWhereToElectricUrl, getWhereClauseParamStats } from "./tables/where-clause-builder" + +// ============================================================================= +// CORS HELPERS +// ============================================================================= + +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Expose-Headers": "*", +} + +const REQUEST_ID_HEADER_NAMES = ["x-request-id", "x-correlation-id", "cf-ray"] as const + +function getRequestId(request: Request): string | undefined { + for (const headerName of REQUEST_ID_HEADER_NAMES) { + const value = request.headers.get(headerName) + if (value) return value + } + return undefined +} + +const annotateHandledError = Effect.fn("ElectricProxy.annotateHandledError")(function* ( + statusCode: number, + errorType: string, +) { + yield* Effect.annotateCurrentSpan("http.response.status_code", statusCode) + yield* Effect.annotateCurrentSpan("error.type", errorType) + yield* Effect.annotateCurrentSpan("error.handled", true) +}) + +// ============================================================================= +// USER FLOW HANDLER +// ============================================================================= + +export const handleUserRequest = (request: Request) => { + const start = Date.now() + const requestId = getRequestId(request) + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("http.method", request.method) + yield* Effect.annotateCurrentSpan("http.request.method", request.method) + yield* Effect.annotateCurrentSpan("http.route", "/v1/shape") + yield* Effect.annotateCurrentSpan("proxy.auth_type", "user") + if (requestId) { + yield* Effect.annotateCurrentSpan("http.request_id", requestId) + } + + // Handle CORS preflight + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }) + } + + // Method check + if (request.method !== "GET" && request.method !== "DELETE") { + yield* Effect.annotateCurrentSpan("proxy.reject_reason", "method_not_allowed") + yield* Effect.annotateCurrentSpan("http.response.status_code", 405) + return new Response("Method not allowed", { + status: 405, + headers: { Allow: "GET, DELETE, OPTIONS", ...CORS_HEADERS }, + }) + } + + // Authenticate user + const user = yield* validateSession(request) + + // Extract and validate table parameter + const url = new URL(request.url) + const tableParam = url.searchParams.get("table") + const tableValidation = validateTable(tableParam) + + if (!tableValidation.valid) { + yield* Effect.annotateCurrentSpan("proxy.reject_reason", "invalid_table") + yield* Effect.annotateCurrentSpan("http.response.status_code", tableParam ? 403 : 400) + return new Response(JSON.stringify({ error: tableValidation.error }), { + status: tableParam ? 403 : 400, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }) + } + + yield* Effect.annotateCurrentSpan("proxy.table", tableValidation.table!) + + // Prepare Electric URL + const originUrl = yield* prepareElectricUrl(request.url) + originUrl.searchParams.set("table", tableValidation.table!) + + // Generate WHERE clause + const whereResult = yield* getWhereClauseForTable(tableValidation.table!, user) + const whereStats = getWhereClauseParamStats(whereResult) + const finalUrl = applyWhereToElectricUrl(originUrl, whereResult) + yield* Effect.annotateCurrentSpan("proxy.where.params_count", whereStats.paramsCount) + yield* Effect.annotateCurrentSpan( + "proxy.where.unique_placeholder_count", + whereStats.uniquePlaceholderCount, + ) + yield* Effect.annotateCurrentSpan("proxy.where.max_placeholder_index", whereStats.maxPlaceholderIndex) + yield* Effect.annotateCurrentSpan("proxy.where.starts_at_one", whereStats.startsAtOne) + yield* Effect.annotateCurrentSpan("proxy.where.has_gaps", whereStats.hasGaps) + yield* Effect.annotateCurrentSpan("proxy.where.length", whereResult.whereClause.length) + yield* Effect.annotateCurrentSpan("proxy.electric_url.length", finalUrl.length) + + // Proxy request to Electric + const response = yield* proxyElectricRequest(finalUrl) + + // Add CORS headers to response + const headers = new Headers(response.headers) + for (const [key, value] of Object.entries(CORS_HEADERS)) { + headers.set(key, value) + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) + }).pipe( + // Auth errors → 401 + Effect.catchTag("ProxyAuthenticationError", (error) => + Effect.gen(function* () { + yield* annotateHandledError(401, "ProxyAuthenticationError") + yield* Effect.logInfo("Authentication failed", { detail: error.detail }) + yield* Metric.update( + Metric.withAttributes(proxyAuthFailures, { + auth_type: "user", + error_tag: "ProxyAuthenticationError", + }), + 1, + ) + return new Response( + JSON.stringify({ + error: error.message, + detail: error.detail, + timestamp: new Date().toISOString(), + hint: "Check if Bearer token is present and valid", + }), + { + status: 401, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }, + ) + }), + ), + // Access/table errors → 500 + Effect.catchTag("TableAccessError", (error: TableAccessError) => + Effect.gen(function* () { + yield* annotateHandledError(500, "TableAccessError") + yield* Effect.logError("Table access error", { error: error.message, table: error.table }) + return new Response( + JSON.stringify({ error: error.message, detail: error.detail, table: error.table }), + { + status: 500, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }, + ) + }), + ), + // Upstream errors → 502 + Effect.catchTag("ElectricProxyError", (error: ElectricProxyError) => + Effect.gen(function* () { + yield* annotateHandledError(502, "ElectricProxyError") + yield* Effect.logError("Electric proxy error", { error: error.message }) + return new Response(JSON.stringify({ error: error.message, detail: error.detail }), { + status: 502, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }) + }), + ), + // Fallback for any unhandled errors - returns error details to client for debugging + Effect.catch((error: unknown) => + Effect.gen(function* () { + const errorTag = (error as { _tag?: string })?._tag ?? "UnknownError" + yield* annotateHandledError(500, errorTag) + yield* Effect.logError("Unhandled error in user flow", { + tag: errorTag, + error: String(error), + }) + return new Response( + JSON.stringify({ + error: errorTag, + detail: String(error), + timestamp: new Date().toISOString(), + }), + { + status: 500, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }, + ) + }), + ), + Effect.tap((response) => + Effect.gen(function* () { + const duration = Date.now() - start + yield* Effect.annotateCurrentSpan("http.status_code", response.status) + yield* Effect.annotateCurrentSpan("http.response.status_code", response.status) + yield* Metric.update( + Metric.withAttributes(proxyRequestsTotal, { + route: "/v1/shape", + auth_type: "user", + status_code: String(response.status), + }), + 1, + ) + yield* Metric.update(proxyRequestDuration, duration) + }), + ), + Effect.withSpan("proxy.handleUserRequest"), + Effect.annotateLogs({ + route: "/v1/shape", + auth_type: "user", + ...(requestId ? { request_id: requestId } : {}), + }), + ) +} + +// ============================================================================= +// BOT FLOW HANDLER +// ============================================================================= + +export const handleBotRequest = (request: Request) => { + const start = Date.now() + const requestId = getRequestId(request) + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("http.method", request.method) + yield* Effect.annotateCurrentSpan("http.request.method", request.method) + yield* Effect.annotateCurrentSpan("http.route", "/bot/v1/shape") + yield* Effect.annotateCurrentSpan("proxy.auth_type", "bot") + if (requestId) { + yield* Effect.annotateCurrentSpan("http.request_id", requestId) + } + + // Handle CORS preflight + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }) + } + + // Method check + if (request.method !== "GET" && request.method !== "DELETE") { + yield* Effect.annotateCurrentSpan("proxy.reject_reason", "method_not_allowed") + yield* Effect.annotateCurrentSpan("http.response.status_code", 405) + return new Response("Method not allowed", { + status: 405, + headers: { Allow: "GET, DELETE, OPTIONS", ...CORS_HEADERS }, + }) + } + + // Authenticate bot + const bot = yield* validateBotToken(request) + + // Extract and validate table parameter + const url = new URL(request.url) + const tableParam = url.searchParams.get("table") + const tableValidation = validateBotTable(tableParam) + + if (!tableValidation.valid) { + yield* Effect.annotateCurrentSpan("proxy.reject_reason", "invalid_table") + yield* Effect.annotateCurrentSpan("http.response.status_code", tableParam ? 403 : 400) + return new Response(JSON.stringify({ error: tableValidation.error }), { + status: tableParam ? 403 : 400, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }) + } + + yield* Effect.annotateCurrentSpan("proxy.table", tableValidation.table!) + + // Prepare Electric URL + const originUrl = yield* prepareElectricUrl(request.url) + originUrl.searchParams.set("table", tableValidation.table!) + + // Generate WHERE clause + const whereResult = yield* getBotWhereClauseForTable(tableValidation.table!, bot) + const whereStats = getWhereClauseParamStats(whereResult) + const finalUrl = applyWhereToElectricUrl(originUrl, whereResult) + yield* Effect.annotateCurrentSpan("proxy.where.params_count", whereStats.paramsCount) + yield* Effect.annotateCurrentSpan( + "proxy.where.unique_placeholder_count", + whereStats.uniquePlaceholderCount, + ) + yield* Effect.annotateCurrentSpan("proxy.where.max_placeholder_index", whereStats.maxPlaceholderIndex) + yield* Effect.annotateCurrentSpan("proxy.where.starts_at_one", whereStats.startsAtOne) + yield* Effect.annotateCurrentSpan("proxy.where.has_gaps", whereStats.hasGaps) + yield* Effect.annotateCurrentSpan("proxy.where.length", whereResult.whereClause.length) + yield* Effect.annotateCurrentSpan("proxy.electric_url.length", finalUrl.length) + + // Proxy request to Electric + const response = yield* proxyElectricRequest(finalUrl) + + // Add CORS headers to response + const headers = new Headers(response.headers) + for (const [key, value] of Object.entries(CORS_HEADERS)) { + headers.set(key, value) + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) + }).pipe( + // Auth errors → 401 + Effect.catchTag("BotAuthenticationError", (error) => + Effect.gen(function* () { + yield* annotateHandledError(401, "BotAuthenticationError") + yield* Effect.logInfo("Bot authentication failed", { + detail: error.detail, + }) + yield* Metric.update( + Metric.withAttributes(proxyAuthFailures, { + auth_type: "bot", + error_tag: "BotAuthenticationError", + }), + 1, + ) + return new Response( + JSON.stringify({ + error: error.message, + detail: error.detail, + timestamp: new Date().toISOString(), + hint: "Check if Authorization header contains valid Bearer token", + }), + { + status: 401, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }, + ) + }), + ), + Effect.catchTag("AccessContextLookupError", (error) => + Effect.gen(function* () { + yield* annotateHandledError(500, "AccessContextLookupError") + yield* Effect.logError("Bot access context lookup failed", { + error: error.message, + entityId: error.entityId, + entityType: error.entityType, + }) + return new Response( + JSON.stringify({ + error: error.message, + entityId: error.entityId, + entityType: error.entityType, + }), + { status: 500, headers: { "Content-Type": "application/json", ...CORS_HEADERS } }, + ) + }), + ), + Effect.catchTag("BotTableAccessError", (error: BotTableAccessError) => + Effect.gen(function* () { + yield* annotateHandledError(500, "BotTableAccessError") + yield* Effect.logError("Bot table access error", { error: error.message, table: error.table }) + return new Response( + JSON.stringify({ error: error.message, detail: error.detail, table: error.table }), + { status: 500, headers: { "Content-Type": "application/json", ...CORS_HEADERS } }, + ) + }), + ), + Effect.catchTag("ElectricProxyError", (error: ElectricProxyError) => + Effect.gen(function* () { + yield* annotateHandledError(502, "ElectricProxyError") + yield* Effect.logError("Electric proxy error (bot)", { error: error.message }) + return new Response(JSON.stringify({ error: error.message, detail: error.detail }), { + status: 502, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }) + }), + ), + // Fallback for any unhandled errors - returns error details to client for debugging + Effect.catch((error: unknown) => + Effect.gen(function* () { + const errorTag = (error as { _tag?: string })?._tag ?? "UnknownError" + yield* annotateHandledError(500, errorTag) + yield* Effect.logError("Unhandled error in bot flow", { tag: errorTag, error: String(error) }) + return new Response( + JSON.stringify({ + error: errorTag, + detail: String(error), + timestamp: new Date().toISOString(), + }), + { + status: 500, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }, + ) + }), + ), + Effect.tap((response) => + Effect.gen(function* () { + const duration = Date.now() - start + yield* Effect.annotateCurrentSpan("http.status_code", response.status) + yield* Effect.annotateCurrentSpan("http.response.status_code", response.status) + yield* Metric.update( + Metric.withAttributes(proxyRequestsTotal, { + route: "/bot/v1/shape", + auth_type: "bot", + status_code: String(response.status), + }), + 1, + ) + yield* Metric.update(proxyRequestDuration, duration) + }), + ), + Effect.withSpan("proxy.handleBotRequest"), + Effect.annotateLogs({ + route: "/bot/v1/shape", + auth_type: "bot", + ...(requestId ? { request_id: requestId } : {}), + }), + ) +} + +// ============================================================================= +// ROUTER +// ============================================================================= + +/** The services {@link handleRequest} needs; each runtime entry provides them. */ +export type HandlerServices = + | Effect.Services> + | Effect.Services> + +/** Routes one request. Never fails: every handled error is rendered as a response. */ +export const handleRequest = (request: Request): Effect.Effect => { + const { pathname } = new URL(request.url) + switch (pathname) { + case "/health": + return Effect.succeed(new Response("OK")) + case "/v1/shape": + return handleUserRequest(request) + case "/bot/v1/shape": + return handleBotRequest(request) + default: + return Effect.succeed(new Response("Not Found", { status: 404 })) + } +} diff --git a/apps/electric-proxy/src/index.ts b/apps/electric-proxy/src/index.ts index c1233ed65..b55ff3c00 100644 --- a/apps/electric-proxy/src/index.ts +++ b/apps/electric-proxy/src/index.ts @@ -1,429 +1,18 @@ +/** + * The Bun entry: a long-running server with a Postgres pool and Redis-backed caches. Kept for + * local `bun run dev`, the e2e suite and the legacy Railway deployment until the DNS cutover to + * the Cloudflare Worker (`worker.ts`), which serves the same {@link handleRequest}. + */ import { BunRuntime } from "@effect/platform-bun" import { ProxyAuth } from "@hazel/auth/proxy" import { Database } from "@hazel/db" -import { ConfigProvider, Effect, Layer, Logger, Metric } from "effect" -import { validateBotToken } from "./auth/bot-auth" -import { validateSession } from "./auth/user-auth" -import { - type AccessContextCacheService, - AccessContextCacheService as AccessContextCache, - RedisPersistenceLive, -} from "./cache" +import { ConfigProvider, Effect, Layer, Logger, Redacted } from "effect" +import { type AccessContextCacheService, AccessContextCacheService as AccessContextCache } from "./cache" +import { RedisPersistenceLive } from "./cache/redis-persistence" import { ProxyConfigService } from "./config" -import { proxyAuthFailures, proxyRequestDuration, proxyRequestsTotal } from "./observability/metrics" +import { handleRequest } from "./handler" import { TracerLive } from "./observability/tracer" -import { type ElectricProxyError, prepareElectricUrl, proxyElectricRequest } from "./proxy/electric-client" -import { type BotTableAccessError, getBotWhereClauseForTable, validateBotTable } from "./tables/bot-tables" -import { getWhereClauseForTable, type TableAccessError, validateTable } from "./tables/user-tables" -import { applyWhereToElectricUrl, getWhereClauseParamStats } from "./tables/where-clause-builder" - -// ============================================================================= -// CORS HELPERS -// ============================================================================= - -const CORS_HEADERS: Record = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Expose-Headers": "*", -} - -const REQUEST_ID_HEADER_NAMES = ["x-request-id", "x-correlation-id", "cf-ray"] as const - -function getRequestId(request: Request): string | undefined { - for (const headerName of REQUEST_ID_HEADER_NAMES) { - const value = request.headers.get(headerName) - if (value) return value - } - return undefined -} - -const annotateHandledError = Effect.fn("ElectricProxy.annotateHandledError")(function* ( - statusCode: number, - errorType: string, -) { - yield* Effect.annotateCurrentSpan("http.response.status_code", statusCode) - yield* Effect.annotateCurrentSpan("error.type", errorType) - yield* Effect.annotateCurrentSpan("error.handled", true) -}) - -// ============================================================================= -// USER FLOW HANDLER -// ============================================================================= - -const handleUserRequest = (request: Request) => { - const start = Date.now() - const requestId = getRequestId(request) - - return Effect.gen(function* () { - yield* Effect.annotateCurrentSpan("http.method", request.method) - yield* Effect.annotateCurrentSpan("http.request.method", request.method) - yield* Effect.annotateCurrentSpan("http.route", "/v1/shape") - yield* Effect.annotateCurrentSpan("proxy.auth_type", "user") - if (requestId) { - yield* Effect.annotateCurrentSpan("http.request_id", requestId) - } - - // Handle CORS preflight - if (request.method === "OPTIONS") { - return new Response(null, { status: 204, headers: CORS_HEADERS }) - } - - // Method check - if (request.method !== "GET" && request.method !== "DELETE") { - yield* Effect.annotateCurrentSpan("proxy.reject_reason", "method_not_allowed") - yield* Effect.annotateCurrentSpan("http.response.status_code", 405) - return new Response("Method not allowed", { - status: 405, - headers: { Allow: "GET, DELETE, OPTIONS", ...CORS_HEADERS }, - }) - } - - // Authenticate user - const user = yield* validateSession(request) - - // Extract and validate table parameter - const url = new URL(request.url) - const tableParam = url.searchParams.get("table") - const tableValidation = validateTable(tableParam) - - if (!tableValidation.valid) { - yield* Effect.annotateCurrentSpan("proxy.reject_reason", "invalid_table") - yield* Effect.annotateCurrentSpan("http.response.status_code", tableParam ? 403 : 400) - return new Response(JSON.stringify({ error: tableValidation.error }), { - status: tableParam ? 403 : 400, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, - }) - } - - yield* Effect.annotateCurrentSpan("proxy.table", tableValidation.table!) - - // Prepare Electric URL - const originUrl = yield* prepareElectricUrl(request.url) - originUrl.searchParams.set("table", tableValidation.table!) - - // Generate WHERE clause - const whereResult = yield* getWhereClauseForTable(tableValidation.table!, user) - const whereStats = getWhereClauseParamStats(whereResult) - const finalUrl = applyWhereToElectricUrl(originUrl, whereResult) - yield* Effect.annotateCurrentSpan("proxy.where.params_count", whereStats.paramsCount) - yield* Effect.annotateCurrentSpan( - "proxy.where.unique_placeholder_count", - whereStats.uniquePlaceholderCount, - ) - yield* Effect.annotateCurrentSpan("proxy.where.max_placeholder_index", whereStats.maxPlaceholderIndex) - yield* Effect.annotateCurrentSpan("proxy.where.starts_at_one", whereStats.startsAtOne) - yield* Effect.annotateCurrentSpan("proxy.where.has_gaps", whereStats.hasGaps) - yield* Effect.annotateCurrentSpan("proxy.where.length", whereResult.whereClause.length) - yield* Effect.annotateCurrentSpan("proxy.electric_url.length", finalUrl.length) - - // Proxy request to Electric - const response = yield* proxyElectricRequest(finalUrl) - - // Add CORS headers to response - const headers = new Headers(response.headers) - for (const [key, value] of Object.entries(CORS_HEADERS)) { - headers.set(key, value) - } - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }) - }).pipe( - // Auth errors → 401 - Effect.catchTag("ProxyAuthenticationError", (error) => - Effect.gen(function* () { - yield* annotateHandledError(401, "ProxyAuthenticationError") - yield* Effect.logInfo("Authentication failed", { detail: error.detail }) - yield* Metric.update( - Metric.withAttributes(proxyAuthFailures, { - auth_type: "user", - error_tag: "ProxyAuthenticationError", - }), - 1, - ) - return new Response( - JSON.stringify({ - error: error.message, - detail: error.detail, - timestamp: new Date().toISOString(), - hint: "Check if Bearer token is present and valid", - }), - { - status: 401, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, - }, - ) - }), - ), - // Access/table errors → 500 - Effect.catchTag("TableAccessError", (error: TableAccessError) => - Effect.gen(function* () { - yield* annotateHandledError(500, "TableAccessError") - yield* Effect.logError("Table access error", { error: error.message, table: error.table }) - return new Response( - JSON.stringify({ error: error.message, detail: error.detail, table: error.table }), - { - status: 500, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, - }, - ) - }), - ), - // Upstream errors → 502 - Effect.catchTag("ElectricProxyError", (error: ElectricProxyError) => - Effect.gen(function* () { - yield* annotateHandledError(502, "ElectricProxyError") - yield* Effect.logError("Electric proxy error", { error: error.message }) - return new Response(JSON.stringify({ error: error.message, detail: error.detail }), { - status: 502, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, - }) - }), - ), - // Fallback for any unhandled errors - returns error details to client for debugging - Effect.catch((error: unknown) => - Effect.gen(function* () { - const errorTag = (error as { _tag?: string })?._tag ?? "UnknownError" - yield* annotateHandledError(500, errorTag) - yield* Effect.logError("Unhandled error in user flow", { - tag: errorTag, - error: String(error), - }) - return new Response( - JSON.stringify({ - error: errorTag, - detail: String(error), - timestamp: new Date().toISOString(), - }), - { - status: 500, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, - }, - ) - }), - ), - Effect.tap((response) => - Effect.gen(function* () { - const duration = Date.now() - start - yield* Effect.annotateCurrentSpan("http.status_code", response.status) - yield* Effect.annotateCurrentSpan("http.response.status_code", response.status) - yield* Metric.update( - Metric.withAttributes(proxyRequestsTotal, { - route: "/v1/shape", - auth_type: "user", - status_code: String(response.status), - }), - 1, - ) - yield* Metric.update(proxyRequestDuration, duration) - }), - ), - Effect.withSpan("proxy.handleUserRequest"), - Effect.annotateLogs({ - route: "/v1/shape", - auth_type: "user", - ...(requestId ? { request_id: requestId } : {}), - }), - ) -} - -// ============================================================================= -// BOT FLOW HANDLER -// ============================================================================= - -const handleBotRequest = (request: Request) => { - const start = Date.now() - const requestId = getRequestId(request) - - return Effect.gen(function* () { - yield* Effect.annotateCurrentSpan("http.method", request.method) - yield* Effect.annotateCurrentSpan("http.request.method", request.method) - yield* Effect.annotateCurrentSpan("http.route", "/bot/v1/shape") - yield* Effect.annotateCurrentSpan("proxy.auth_type", "bot") - if (requestId) { - yield* Effect.annotateCurrentSpan("http.request_id", requestId) - } - - // Handle CORS preflight - if (request.method === "OPTIONS") { - return new Response(null, { status: 204, headers: CORS_HEADERS }) - } - - // Method check - if (request.method !== "GET" && request.method !== "DELETE") { - yield* Effect.annotateCurrentSpan("proxy.reject_reason", "method_not_allowed") - yield* Effect.annotateCurrentSpan("http.response.status_code", 405) - return new Response("Method not allowed", { - status: 405, - headers: { Allow: "GET, DELETE, OPTIONS", ...CORS_HEADERS }, - }) - } - - // Authenticate bot - const bot = yield* validateBotToken(request) - - // Extract and validate table parameter - const url = new URL(request.url) - const tableParam = url.searchParams.get("table") - const tableValidation = validateBotTable(tableParam) - - if (!tableValidation.valid) { - yield* Effect.annotateCurrentSpan("proxy.reject_reason", "invalid_table") - yield* Effect.annotateCurrentSpan("http.response.status_code", tableParam ? 403 : 400) - return new Response(JSON.stringify({ error: tableValidation.error }), { - status: tableParam ? 403 : 400, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, - }) - } - - yield* Effect.annotateCurrentSpan("proxy.table", tableValidation.table!) - - // Prepare Electric URL - const originUrl = yield* prepareElectricUrl(request.url) - originUrl.searchParams.set("table", tableValidation.table!) - - // Generate WHERE clause - const whereResult = yield* getBotWhereClauseForTable(tableValidation.table!, bot) - const whereStats = getWhereClauseParamStats(whereResult) - const finalUrl = applyWhereToElectricUrl(originUrl, whereResult) - yield* Effect.annotateCurrentSpan("proxy.where.params_count", whereStats.paramsCount) - yield* Effect.annotateCurrentSpan( - "proxy.where.unique_placeholder_count", - whereStats.uniquePlaceholderCount, - ) - yield* Effect.annotateCurrentSpan("proxy.where.max_placeholder_index", whereStats.maxPlaceholderIndex) - yield* Effect.annotateCurrentSpan("proxy.where.starts_at_one", whereStats.startsAtOne) - yield* Effect.annotateCurrentSpan("proxy.where.has_gaps", whereStats.hasGaps) - yield* Effect.annotateCurrentSpan("proxy.where.length", whereResult.whereClause.length) - yield* Effect.annotateCurrentSpan("proxy.electric_url.length", finalUrl.length) - - // Proxy request to Electric - const response = yield* proxyElectricRequest(finalUrl) - - // Add CORS headers to response - const headers = new Headers(response.headers) - for (const [key, value] of Object.entries(CORS_HEADERS)) { - headers.set(key, value) - } - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }) - }).pipe( - // Auth errors → 401 - Effect.catchTag("BotAuthenticationError", (error) => - Effect.gen(function* () { - yield* annotateHandledError(401, "BotAuthenticationError") - yield* Effect.logInfo("Bot authentication failed", { - detail: error.detail, - }) - yield* Metric.update( - Metric.withAttributes(proxyAuthFailures, { - auth_type: "bot", - error_tag: "BotAuthenticationError", - }), - 1, - ) - return new Response( - JSON.stringify({ - error: error.message, - detail: error.detail, - timestamp: new Date().toISOString(), - hint: "Check if Authorization header contains valid Bearer token", - }), - { - status: 401, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, - }, - ) - }), - ), - Effect.catchTag("AccessContextLookupError", (error) => - Effect.gen(function* () { - yield* annotateHandledError(500, "AccessContextLookupError") - yield* Effect.logError("Bot access context lookup failed", { - error: error.message, - entityId: error.entityId, - entityType: error.entityType, - }) - return new Response( - JSON.stringify({ - error: error.message, - entityId: error.entityId, - entityType: error.entityType, - }), - { status: 500, headers: { "Content-Type": "application/json", ...CORS_HEADERS } }, - ) - }), - ), - Effect.catchTag("BotTableAccessError", (error: BotTableAccessError) => - Effect.gen(function* () { - yield* annotateHandledError(500, "BotTableAccessError") - yield* Effect.logError("Bot table access error", { error: error.message, table: error.table }) - return new Response( - JSON.stringify({ error: error.message, detail: error.detail, table: error.table }), - { status: 500, headers: { "Content-Type": "application/json", ...CORS_HEADERS } }, - ) - }), - ), - Effect.catchTag("ElectricProxyError", (error: ElectricProxyError) => - Effect.gen(function* () { - yield* annotateHandledError(502, "ElectricProxyError") - yield* Effect.logError("Electric proxy error (bot)", { error: error.message }) - return new Response(JSON.stringify({ error: error.message, detail: error.detail }), { - status: 502, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, - }) - }), - ), - // Fallback for any unhandled errors - returns error details to client for debugging - Effect.catch((error: unknown) => - Effect.gen(function* () { - const errorTag = (error as { _tag?: string })?._tag ?? "UnknownError" - yield* annotateHandledError(500, errorTag) - yield* Effect.logError("Unhandled error in bot flow", { tag: errorTag, error: String(error) }) - return new Response( - JSON.stringify({ - error: errorTag, - detail: String(error), - timestamp: new Date().toISOString(), - }), - { - status: 500, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, - }, - ) - }), - ), - Effect.tap((response) => - Effect.gen(function* () { - const duration = Date.now() - start - yield* Effect.annotateCurrentSpan("http.status_code", response.status) - yield* Effect.annotateCurrentSpan("http.response.status_code", response.status) - yield* Metric.update( - Metric.withAttributes(proxyRequestsTotal, { - route: "/bot/v1/shape", - auth_type: "bot", - status_code: String(response.status), - }), - 1, - ) - yield* Metric.update(proxyRequestDuration, duration) - }), - ), - Effect.withSpan("proxy.handleBotRequest"), - Effect.annotateLogs({ - route: "/bot/v1/shape", - auth_type: "bot", - ...(requestId ? { request_id: requestId } : {}), - }), - ) -} +import { ElectricUpstream } from "./proxy/electric-upstream" // ============================================================================= // LAYERS @@ -449,6 +38,22 @@ const LoggerLive = Layer.unwrap( }), ).pipe(Layer.provide(ProxyConfigService.layer)) +/** `ELECTRIC_URL`, authenticated with `ELECTRIC_SECRET` (self-hosted) or a source id + secret (Electric Cloud). */ +const ElectricUpstreamLive = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ProxyConfigService + const authParams: Record = {} + // Self-hosted (`ELECTRIC_SECRET`) wins; the Cloud pair applies only without it. + if (config.electricSecret !== undefined) { + authParams.secret = Redacted.value(config.electricSecret) + } else if (config.electricSourceId !== undefined && config.electricSourceSecret !== undefined) { + authParams.source_id = config.electricSourceId + authParams.secret = config.electricSourceSecret + } + return ElectricUpstream.layerUrl({ baseUrl: config.electricUrl, authParams }) + }), +).pipe(Layer.provide(ProxyConfigService.layer)) + // Cache layer: AccessContextCache requires ResultPersistence and Database const CacheLive = AccessContextCache.layer.pipe( Layer.provide(RedisPersistenceLive), @@ -467,6 +72,7 @@ const ProxyAuthLive = ProxyAuth.layer.pipe( const MainLive = DatabaseLive.pipe( Layer.provideMerge(ProxyConfigService.layer), Layer.provideMerge(LoggerLive), + Layer.provideMerge(ElectricUpstreamLive), Layer.provideMerge(CacheLive), Layer.provideMerge(TracerLive), Layer.provideMerge(ProxyAuthLive), @@ -485,15 +91,20 @@ const ServerLive = Layer.effectDiscard( electricUrl: config.electricUrl, allowedOrigin: config.allowedOrigin, }) - if (!config.isDev && (!config.electricSourceId || !config.electricSourceSecret)) { - yield* Effect.logWarning("Electric source credentials missing; upstream requests may fail", { + if ( + !config.isDev && + !config.electricSecret && + (!config.electricSourceId || !config.electricSourceSecret) + ) { + yield* Effect.logWarning("Electric credentials missing; upstream requests may fail", { + hasElectricSecret: !!config.electricSecret, hasSourceId: !!config.electricSourceId, - hasSecret: !!config.electricSourceSecret, + hasSourceSecret: !!config.electricSourceSecret, }) } const serviceMap = yield* Effect.context< - ProxyConfigService | Database.Database | AccessContextCacheService | ProxyAuth + ProxyConfigService | Database.Database | AccessContextCacheService | ProxyAuth | ElectricUpstream >() const run = Effect.runPromiseWith(serviceMap) @@ -505,11 +116,9 @@ const ServerLive = Layer.effectDiscard( idleTimeout: 120, routes: { "/health": new Response("OK"), // Static response - zero allocation - "/v1/shape": (req) => run(handleUserRequest(req)), - "/bot/v1/shape": (req) => run(handleBotRequest(req)), }, - fetch() { - return new Response("Not Found", { status: 404 }) + fetch(req) { + return run(handleRequest(req)) }, }), ), diff --git a/apps/electric-proxy/src/proxy/electric-client.ts b/apps/electric-proxy/src/proxy/electric-client.ts index b0c6b7753..9f6f9d12d 100644 --- a/apps/electric-proxy/src/proxy/electric-client.ts +++ b/apps/electric-proxy/src/proxy/electric-client.ts @@ -1,7 +1,7 @@ import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client" import { Effect, Metric, Schema } from "effect" -import { ProxyConfigService } from "../config" import { proxyElectricDuration, proxyElectricErrors } from "../observability/metrics" +import { ElectricUpstream } from "./electric-upstream" /** * Error thrown when Electric proxy request fails @@ -13,7 +13,7 @@ export class ElectricProxyError extends Schema.TaggedError() /** * Prepares the Electric SQL proxy URL from a request URL - * Copies over Electric-specific query params and adds auth if configured + * Copies over Electric-specific query params and adds the upstream's auth params * * @param requestUrl - The incoming request URL * @returns Effect that succeeds with the prepared Electric SQL origin URL @@ -21,9 +21,9 @@ export class ElectricProxyError extends Schema.TaggedError() export const prepareElectricUrl = Effect.fn("ElectricClient.prepareElectricUrl")(function* ( requestUrl: string, ) { - const config = yield* ProxyConfigService + const upstream = yield* ElectricUpstream const url = new URL(requestUrl) - const originUrl = new URL(`${config.electricUrl}/v1/shape`) + const originUrl = new URL(`${upstream.baseUrl}/v1/shape`) // Copy Electric-specific query params url.searchParams.forEach((value, key) => { @@ -32,14 +32,12 @@ export const prepareElectricUrl = Effect.fn("ElectricClient.prepareElectricUrl") } }) - // Add Electric Cloud authentication if configured - const sourceId = config.electricSourceId - const sourceSecret = config.electricSourceSecret - const hasElectricAuth = sourceId !== undefined && sourceSecret !== undefined - yield* Effect.annotateCurrentSpan("electric.auth.configured", hasElectricAuth) - if (hasElectricAuth) { - originUrl.searchParams.set("source_id", sourceId) - originUrl.searchParams.set("secret", sourceSecret) + // Electric's own authentication (ELECTRIC_SECRET), set after the copy so a client cannot override it + const authEntries = Object.entries(upstream.authParams) + yield* Effect.annotateCurrentSpan("electric.auth.configured", authEntries.length > 0) + yield* Effect.annotateCurrentSpan("electric.upstream", upstream.kind) + for (const [key, value] of authEntries) { + originUrl.searchParams.set(key, value) } return originUrl @@ -55,13 +53,14 @@ export const prepareElectricUrl = Effect.fn("ElectricClient.prepareElectricUrl") export const proxyElectricRequest = Effect.fn("ElectricClient.proxyElectricRequest")(function* ( originUrl: string | URL, ) { + const upstream = yield* ElectricUpstream const urlStr = typeof originUrl === "string" ? originUrl : originUrl.toString() const targetUrl = new URL(urlStr) yield* Effect.annotateCurrentSpan("url.path", targetUrl.pathname) yield* Effect.annotateCurrentSpan("server.address", targetUrl.host) const start = Date.now() const response = yield* Effect.tryPromise({ - try: () => fetch(urlStr), + try: () => upstream.fetch(urlStr), catch: (error) => new ElectricProxyError({ message: "Failed to fetch from Electric SQL", diff --git a/apps/electric-proxy/src/proxy/electric-upstream.test.ts b/apps/electric-proxy/src/proxy/electric-upstream.test.ts new file mode 100644 index 000000000..b461e5922 --- /dev/null +++ b/apps/electric-proxy/src/proxy/electric-upstream.test.ts @@ -0,0 +1,66 @@ +import { Effect, Layer } from "effect" +import { describe, expect, it } from "vitest" +import { prepareElectricUrl, proxyElectricRequest } from "./electric-client" +import { ELECTRIC_INSTANCE_NAME, type ElectricNamespace, ElectricUpstream } from "./electric-upstream" + +const recordingNamespace = () => { + const calls: Array<{ name: string; url: string }> = [] + const namespace: ElectricNamespace = { + getByName: (name) => ({ + fetch: (url) => { + calls.push({ name, url }) + return Promise.resolve( + new Response("[]", { status: 200, headers: { "electric-offset": "0_0" } }), + ) + }, + }), + } + return { calls, namespace } +} + +const run = (effect: Effect.Effect, layer: Layer.Layer) => + Effect.runPromise(Effect.provide(effect, layer)) + +describe("ElectricUpstream", () => { + it("forwards to the single Electric Durable Object with the secret appended", async () => { + const { calls, namespace } = recordingNamespace() + const layer = ElectricUpstream.layerDurableObject(namespace, { secret: "s3cret" }) + + const response = await run( + Effect.gen(function* () { + const url = yield* prepareElectricUrl( + "https://electric.hazel.sh/v1/shape?table=messages&offset=-1&secret=client&evil=1", + ) + return yield* proxyElectricRequest(url) + }), + layer, + ) + + expect(response.status).toBe(200) + expect(response.headers.get("electric-offset")).toBe("0_0") + expect(calls).toHaveLength(1) + expect(calls[0]?.name).toBe(ELECTRIC_INSTANCE_NAME) + const forwarded = new URL(calls[0]!.url) + expect(forwarded.pathname).toBe("/v1/shape") + expect(forwarded.searchParams.get("offset")).toBe("-1") + // The proxy's secret wins over anything the client sent; non-protocol params are dropped. + expect(forwarded.searchParams.get("secret")).toBe("s3cret") + expect(forwarded.searchParams.has("evil")).toBe(false) + }) + + it("builds shape URLs on a plain ELECTRIC_URL", async () => { + const url = await run( + prepareElectricUrl("http://localhost:8184/v1/shape?offset=-1"), + ElectricUpstream.layerUrl({ baseUrl: "http://localhost:3333/" }), + ) + expect(url.toString()).toBe("http://localhost:3333/v1/shape?offset=-1") + }) + + it("answers 503 when no Electric is configured", async () => { + const response = await run( + proxyElectricRequest("http://electric.invalid/v1/shape?offset=-1"), + ElectricUpstream.layerUnconfigured, + ) + expect(response.status).toBe(503) + }) +}) diff --git a/apps/electric-proxy/src/proxy/electric-upstream.ts b/apps/electric-proxy/src/proxy/electric-upstream.ts new file mode 100644 index 000000000..75a6e0c3e --- /dev/null +++ b/apps/electric-proxy/src/proxy/electric-upstream.ts @@ -0,0 +1,73 @@ +import { Context, Layer } from "effect" + +/** The single Electric instance's Durable Object name. Electric owns one replication slot. */ +export const ELECTRIC_INSTANCE_NAME = "electric" + +/** + * The slice of a `DurableObjectNamespace` the proxy uses. Structural, so this module needs neither + * the Workers nor the Bun ambient types. + */ +export interface ElectricNamespace { + readonly getByName: (name: string) => { + readonly fetch: (input: string, init?: RequestInit) => Promise + } +} + +/** Where shape requests go: the Electric container's Durable Object, or a plain URL. */ +export interface ElectricUpstreamShape { + readonly kind: "durable-object" | "url" | "unconfigured" + /** Base URL shape requests are built on (`/v1/shape`). */ + readonly baseUrl: string + /** Query params Electric authenticates with (`secret`; Electric Cloud's `source_id`). */ + readonly authParams: Readonly> + /** Sends one shape request upstream. The response body is passed through unread. */ + readonly fetch: (url: string) => Promise +} + +export class ElectricUpstream extends Context.Service()( + "ElectricUpstream", +) { + /** A plain Electric URL: docker Electric in dev, or Electric Cloud from the legacy Bun entry. */ + static readonly layerUrl = (options: { + readonly baseUrl: string + readonly authParams?: Readonly> + }): Layer.Layer => + Layer.succeed(this, { + kind: "url", + baseUrl: options.baseUrl.replace(/\/+$/, ""), + authParams: options.authParams ?? {}, + fetch: (url) => fetch(url), + }) + + /** + * The self-hosted Electric container, through its Durable Object. The host is a placeholder: + * the container class forwards every request to Electric's port whatever the URL's host. + */ + static readonly layerDurableObject = ( + namespace: ElectricNamespace, + authParams: Readonly> = {}, + ): Layer.Layer => + Layer.succeed(this, { + kind: "durable-object", + baseUrl: "http://electric", + authParams, + fetch: (url) => namespace.getByName(ELECTRIC_INSTANCE_NAME).fetch(url), + }) + + /** + * No Electric for this deployment (PR previews): every shape request answers 503, and the + * web app falls back to its non-synced reads. + */ + static readonly layerUnconfigured: Layer.Layer = Layer.succeed(this, { + kind: "unconfigured", + baseUrl: "http://electric.invalid", + authParams: {}, + fetch: () => + Promise.resolve( + new Response(JSON.stringify({ error: "Electric is not configured for this deployment" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + ), + }) +} diff --git a/apps/electric-proxy/src/worker.ts b/apps/electric-proxy/src/worker.ts new file mode 100644 index 000000000..4f4174459 --- /dev/null +++ b/apps/electric-proxy/src/worker.ts @@ -0,0 +1,162 @@ +/** + * The electric-proxy Worker: authenticates Clerk users and bots, pins each shape's where-clause + * and forwards to Electric, streaming the response through. Electric is the self-hosted + * container's Durable Object (`ELECTRIC`), or `ELECTRIC_URL` (docker Electric under `alchemy dev`). + */ +import { ProxyAuth } from "@hazel/auth/proxy" +import { Database } from "@hazel/db" +import { layerKvResultPersistence } from "@hazel/effect-cloudflare/KvPersistence" +import { cachedRecoverable } from "@hazel/infra/cached-recoverable" +import { HAZEL_DB_BINDING, HazelStack, hazelWorkerProps, readHazelDbBinding } from "@hazel/infra/cloudflare" +import { merge, requireSecretEntry, telemetryEnv } from "@hazel/infra/env" +import { forIsolate, isolateContext } from "@hazel/infra/worker-http" +import { workerEnvLayer } from "@hazel/infra/worker-runtime" +import type { KVNamespace } from "@cloudflare/workers-types" +import * as Cloudflare from "alchemy/Cloudflare" +import { Config, Effect, Layer, Logger, Option, Redacted, Schema } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/http" +import { electricProxyEnv, mapleObservability, ProxyCache } from "../resources.ts" +import { AccessContextCacheService } from "./cache" +import { handleRequest } from "./handler" +import { type ElectricNamespace, ElectricUpstream } from "./proxy/electric-upstream" + +/** KV namespace backing the bot access-context and Clerk user-lookup caches (was Redis). */ +const PROXY_CACHE_BINDING = "PROXY_CACHE" +/** The Electric container's Durable Object namespace, bound from the `electric` Worker. */ +const ELECTRIC_BINDING = "ELECTRIC" + +/** `__ALCHEMY_RUNTIME__` folds to `true` in the bundle, so the stack-side branch is tree-shaken. */ +const props = Effect.gen(function* () { + if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } + const stack = yield* HazelStack + const { stage, domains } = stack + return { + main: import.meta.url, + ...hazelWorkerProps("electric-proxy", stack), + workersDev: stage.kind !== "prd", + // Same hostname as the Railway deployment, so the web app's VITE_ELECTRIC_URL is unchanged. + domain: domains.electric, + observability: mapleObservability, + env: { + [HAZEL_DB_BINDING]: stack.db.hyperdrive, + [PROXY_CACHE_BINDING]: yield* ProxyCache, + ...(yield* electricProxyEnv(stack)), + ...(yield* merge(requireSecretEntry("CLERK_SECRET_KEY"), telemetryEnv(stage))), + }, + } +}) + +/** A binding the deploy should have attached is missing or of the wrong kind. */ +export class ProxyBindingError extends Schema.TaggedError()("ProxyBindingError", { + message: Schema.String, + binding: Schema.String, +}) {} + +const isKvNamespace = (value: unknown): value is KVNamespace => + typeof value === "object" && + value !== null && + typeof (value as { get?: unknown }).get === "function" && + typeof (value as { put?: unknown }).put === "function" + +const isElectricNamespace = (value: unknown): value is ElectricNamespace => + typeof value === "object" && + value !== null && + typeof (value as { getByName?: unknown }).getByName === "function" + +/** The container's namespace when bound, else `ELECTRIC_URL`, else a 503 upstream. */ +const electricUpstreamLayer = (env: Record) => + Layer.unwrap( + Effect.gen(function* () { + const secret = yield* Config.option(Config.Redacted("ELECTRIC_SECRET")) + const authParams = Option.match(secret, { + onNone: () => ({}), + onSome: (value) => ({ secret: Redacted.value(value) }), + }) + const namespace = env[ELECTRIC_BINDING] + if (isElectricNamespace(namespace)) { + return ElectricUpstream.layerDurableObject(namespace, authParams) + } + const electricUrl = yield* Config.option(Config.String("ELECTRIC_URL")) + return Option.match(electricUrl, { + onNone: () => ElectricUpstream.layerUnconfigured, + onSome: (baseUrl) => ElectricUpstream.layerUrl({ baseUrl, authParams }), + }) + }), + ) + +/** + * The per-isolate services. The database is request-scoped (`Database.layerRequestScoped` reads + * the request's `DatabaseConnection`); the bot access-context cache is built per request too, + * since its in-flight lookups would otherwise be shared across requests' I/O contexts. + */ +const isolateLayer = (env: Record) => + Layer.unwrap( + Effect.gen(function* () { + const kv = env[PROXY_CACHE_BINDING] + if (!isKvNamespace(kv)) { + return yield* new ProxyBindingError({ + message: "The proxy cache KV namespace is not bound", + binding: PROXY_CACHE_BINDING, + }) + } + return Layer.mergeAll(ProxyAuth.layer, electricUpstreamLayer(env)).pipe( + Layer.provideMerge(Database.layerRequestScoped), + Layer.provideMerge(layerKvResultPersistence(kv)), + ) + }), + ).pipe( + Layer.provideMerge(Logger.layer([Logger.withConsoleLog(Logger.formatStructured)])), + Layer.provideMerge(workerEnvLayer(env)), + ) + +export default class ElectricProxy extends Cloudflare.Worker()( + "electric-proxy", + props, + Effect.gen(function* () { + const env: Record = yield* Cloudflare.WorkerEnvironment + const exec = yield* Cloudflare.WorkerExecutionContext + const isolate = isolateContext(yield* Effect.context()) + + // Built on the first request, not in init (init also runs at plan time, where every + // `Config` read would be auto-bound). A failed build answers 500 and the next request retries. + const services = yield* cachedRecoverable( + forIsolate(isolate)(Layer.build(isolateLayer(env))).pipe(Effect.orDie), + ) + + /** One lazily-dialed Postgres client per request, via Hyperdrive; closed after the response. */ + const withRequestDatabase = (effect: Effect.Effect) => { + const binding = readHazelDbBinding(env) + // No binding: `Database.layerRequestScoped` names the missing connection on first use. + if (Option.isNone(binding)) return effect + return Effect.acquireUseRelease( + Effect.sync(() => Database.makeRequestConnection(binding.value.connectionString)), + (connection) => + Effect.provideService(effect, Database.DatabaseConnection, { db: connection.db }), + // Only auth and where-clauses touch the database, so the socket closes before the + // (possibly long-polling) body streams; `waitUntil` keeps the close off the response. + (connection) => + exec.waitUntil( + Effect.tryPromise(() => connection.end()).pipe( + Effect.catchTag("UnknownError", (error) => + Effect.logWarning("Failed to close the request's Postgres client", error), + ), + ), + ), + ) + } + + return { + fetch: Effect.gen(function* () { + const context = yield* services + const request = yield* HttpServerRequest.toWeb(yield* HttpServerRequest.HttpServerRequest) + const response = yield* handleRequest(request).pipe( + Effect.provide(AccessContextCacheService.layer, { local: true }), + withRequestDatabase, + Effect.provideContext(context), + ) + // Raw passthrough: the bridge returns this Response as-is, so the body streams unbuffered. + return HttpServerResponse.raw(response, { status: response.status }) + }).pipe(Effect.orDie), + } + }), +) {} diff --git a/apps/electric-proxy/tsconfig.json b/apps/electric-proxy/tsconfig.json index 92fd4db95..23abd4b61 100644 --- a/apps/electric-proxy/tsconfig.json +++ b/apps/electric-proxy/tsconfig.json @@ -1,6 +1,8 @@ { "compilerOptions": { "lib": ["ESNext"], + // Bun for the Bun entry, workers-types for the Worker and the Electric container host. + "types": ["bun", "@cloudflare/workers-types"], "target": "ESNext", "module": "Preserve", "moduleDetection": "force", diff --git a/apps/landing/alchemy.run.ts b/apps/landing/alchemy.run.ts new file mode 100644 index 000000000..fddb7e9c1 --- /dev/null +++ b/apps/landing/alchemy.run.ts @@ -0,0 +1,21 @@ +/** The marketing site: the Astro static build served as Worker static assets. */ +import { HazelStack, resolveWorkerName } from "@hazel/infra/cloudflare" +import * as Cloudflare from "alchemy/Cloudflare" +import { Effect } from "effect" + +const props = Effect.gen(function* () { + const { stage, domains } = yield* HazelStack + return { + name: resolveWorkerName("landing", stage), + cwd: new URL(".", import.meta.url).pathname, + command: "bun run build", + outdir: "dist", + dev: { command: "bun run dev" }, + memo: { include: ["**/*", "../../packages/ui/src/**"], lockfile: true }, + compatibility: { date: "2026-10-01" }, + workersDev: stage.kind !== "prd", + domain: domains.landing, + } +}) + +export default class Landing extends Cloudflare.Website.StaticSite()("landing", props) {} diff --git a/apps/landing/wrangler.jsonc b/apps/landing/wrangler.jsonc deleted file mode 100644 index c677e5158..000000000 --- a/apps/landing/wrangler.jsonc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "hazel-landing", - "compatibility_date": "2025-12-01", - "assets": { - "directory": "./dist", - }, -} diff --git a/apps/link-preview-worker/alchemy.run.ts b/apps/link-preview-worker/alchemy.run.ts new file mode 100644 index 000000000..debd13dec --- /dev/null +++ b/apps/link-preview-worker/alchemy.run.ts @@ -0,0 +1,25 @@ +/** The link-preview Worker: an async (non-Effect-runtime) Worker over its own `src/index.ts`. */ +import { HazelStack, hazelWorkerProps, stageProps } from "@hazel/infra/cloudflare" +import * as Cloudflare from "alchemy/Cloudflare" +import { Effect } from "effect" + +/** Pre-alchemy wrangler title, kept for prd so `--adopt` reuses the existing namespace. */ +export const LinkCache = Cloudflare.KV.Namespace( + "link-cache", + stageProps("link-cache", (name, stage) => ({ + title: stage.kind === "prd" ? "link-preview-worker-link-cache" : name, + })), +) + +export default Effect.gen(function* () { + const stack = yield* HazelStack + const linkCache = yield* LinkCache + return yield* Cloudflare.Worker("link-preview", { + ...hazelWorkerProps("link-preview", stack), + main: new URL("./src/index.ts", import.meta.url).pathname, + observability: { enabled: true }, + workersDev: stack.stage.kind !== "prd", + domain: stack.domains.linkPreview, + env: { LINK_CACHE: linkCache }, + }) +}) diff --git a/apps/link-preview-worker/package.json b/apps/link-preview-worker/package.json index 752c59768..7831290a7 100644 --- a/apps/link-preview-worker/package.json +++ b/apps/link-preview-worker/package.json @@ -5,7 +5,6 @@ "type": "module", "main": "src/api.ts", "scripts": { - "deploy": "wrangler deploy", "dev:local": "wrangler dev --port 5471", "start": "wrangler dev", "test": "vitest", diff --git a/apps/link-preview-worker/wrangler.jsonc b/apps/link-preview-worker/wrangler.jsonc index 13ce0cfe0..ccfbe8dd0 100644 --- a/apps/link-preview-worker/wrangler.jsonc +++ b/apps/link-preview-worker/wrangler.jsonc @@ -1,3 +1,4 @@ +// Local `wrangler dev` / `wrangler types` only. Deploys go through alchemy: apps/link-preview-worker/alchemy.run.ts /** * For more details on how to configure Wrangler, refer to: * https://developers.cloudflare.com/workers/wrangler/configuration/ diff --git a/apps/web/alchemy.run.ts b/apps/web/alchemy.run.ts new file mode 100644 index 000000000..f032f50e1 --- /dev/null +++ b/apps/web/alchemy.run.ts @@ -0,0 +1,54 @@ +/** + * The web SPA: `vite build` output served as Worker static assets (no Worker code). + * `VITE_*` keys in `env` are build inputs: they reach `vite build` via the process env and are + * folded into the memo hash, so changing one rebuilds with no source change. + */ +import { HazelStack, resolveWorkerName } from "@hazel/infra/cloudflare" +import { plainFrom } from "@hazel/infra/env" +import * as Cloudflare from "alchemy/Cloudflare" +import { Effect } from "effect" + +const props = Effect.gen(function* () { + const { stage, domains, urls } = yield* HazelStack + return { + name: resolveWorkerName("web", stage), + cwd: new URL(".", import.meta.url).pathname, + // `bun run build` also runs `tsc`; CI typechecks separately. + command: "bunx vite build", + outdir: "dist", + dev: { command: "bun run dev" }, + assets: { + // Deep links serve the shell in place. + notFoundHandling: "single-page-application" as const, + }, + // Also hash the workspace sources the SPA bundles. + memo: { + include: ["**/*", "../../packages/*/src/**", "../../libs/*/src/**"], + lockfile: true, + }, + compatibility: { date: "2026-10-01" }, + workersDev: stage.kind !== "prd", + domain: domains.web, + env: { + VITE_BACKEND_URL: yield* plainFrom(["VITE_BACKEND_URL"], urls.api), + VITE_ELECTRIC_URL: yield* plainFrom(["VITE_ELECTRIC_URL"], `${urls.electric}/v1/shape`), + VITE_RIVET_URL: yield* plainFrom(["VITE_RIVET_URL"], urls.rivet), + VITE_R2_PUBLIC_URL: yield* plainFrom(["VITE_R2_PUBLIC_URL"], "https://cdn.hazel.sh"), + VITE_CLERK_PUBLISHABLE_KEY: yield* plainFrom( + ["VITE_CLERK_PUBLISHABLE_KEY", "CLERK_PUBLISHABLE_KEY"], + "", + ), + VITE_PUBLIC_POSTHOG_KEY: yield* plainFrom(["VITE_PUBLIC_POSTHOG_KEY"], ""), + VITE_PUBLIC_POSTHOG_HOST: yield* plainFrom(["VITE_PUBLIC_POSTHOG_HOST"], "https://ph.hazel.sh"), + VITE_MAPLE_PUBLIC_KEY: yield* plainFrom(["VITE_MAPLE_PUBLIC_KEY"], ""), + VITE_OTEL_ENVIRONMENT: stage.kind === "prd" ? "production" : "development", + VITE_COMMIT_SHA: yield* plainFrom(["VITE_COMMIT_SHA", "COMMIT_SHA", "GITHUB_SHA"], ""), + }, + } +}).pipe( + // StaticSite requires `never` errors; every read here has a default. + Effect.orDie, +) + +// Alchemy keys state by logical id: renaming `web` replaces the Worker behind app.hazel.sh. +export default class Web extends Cloudflare.Website.StaticSite()("web", props) {} diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc deleted file mode 100644 index cc2d253ef..000000000 --- a/apps/web/wrangler.jsonc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "hazel-app", - "compatibility_date": "2025-06-01", - "assets": { - "not_found_handling": "single-page-application", - "directory": "./dist/", - }, -} diff --git a/bun.lock b/bun.lock index cac098dd8..041dc4afb 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,14 @@ "": { "name": "maki-chat", "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", "@effect/vitest": "catalog:effect", + "@hazel/infra": "workspace:*", "@rolldown/plugin-babel": "^0.2.1", + "@types/node": "^24", "@vitest/coverage-v8": "^4.1.0", + "alchemy": "catalog:alchemy", + "effect": "catalog:effect", "oxfmt": "^0.40.0", "oxlint": "^1.55.0", "rollup-plugin-visualizer": "7.0.1", @@ -42,18 +47,25 @@ "@effect/sql-pg": "catalog:effect", "@hazel/auth": "workspace:*", "@hazel/backend-core": "workspace:*", + "@hazel/bot-gateway": "workspace:*", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/effect-bun": "workspace:*", + "@hazel/effect-cloudflare": "workspace:*", + "@hazel/infra": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", + "alchemy": "catalog:alchemy", + "aws4fetch": "^1.0.20", "dfx": "^1.1.0", "drizzle-orm": "^0.45.1", "effect": "catalog:effect", "jose": "^6.1.3", "pg": "^8.16.3", + "uuid": "^13", }, "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", "@testcontainers/postgresql": "^10.18.0", "@types/bun": "1.3.9", "drizzle-kit": "^0.31.8", @@ -68,12 +80,16 @@ "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/effect-bun": "workspace:*", + "@hazel/infra": "workspace:*", "@hazel/schema": "workspace:*", + "alchemy": "catalog:alchemy", "effect": "catalog:effect", }, "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", "@types/bun": "1.3.9", "typescript": "^5.9.3", + "vitest": "^4.1.0", }, }, "apps/cluster": { @@ -129,7 +145,6 @@ "fumadocs-mdx": "14.2.6", "fumadocs-ui": "16.5.0", "lucide-react": "^0.577.0", - "nitro": "^3.0.1-alpha.2", "react": "19.2.4", "react-dom": "19.2.4", "tailwind-merge": "^3.5.0", @@ -151,16 +166,21 @@ "name": "@hazel/electric-proxy", "version": "0.0.0", "dependencies": { + "@cloudflare/containers": "0.3.7", "@effect/platform-bun": "catalog:effect", "@electric-sql/client": "1.5.15", "@hazel/auth": "workspace:*", "@hazel/db": "workspace:*", "@hazel/effect-bun": "workspace:*", + "@hazel/effect-cloudflare": "workspace:*", + "@hazel/infra": "workspace:*", "@hazel/schema": "workspace:*", + "alchemy": "catalog:alchemy", "drizzle-orm": "^0.45.1", "effect": "catalog:effect", }, "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", "@types/bun": "1.3.9", "typescript": "^5.9.3", }, @@ -521,6 +541,31 @@ "typescript": "^5.9.3", }, }, + "packages/effect-cloudflare": { + "name": "@hazel/effect-cloudflare", + "version": "0.0.0", + "dependencies": { + "effect": "catalog:effect", + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", + "typescript": "^5.9.3", + }, + }, + "packages/infra": { + "name": "@hazel/infra", + "version": "0.0.0", + "dependencies": { + "alchemy": "catalog:alchemy", + "effect": "catalog:effect", + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", + "@types/node": "^24.0.0", + "typescript": "^5.9.3", + "vitest": "^4.1.0", + }, + }, "packages/integrations": { "name": "@hazel/integrations", "version": "0.0.0", @@ -593,6 +638,10 @@ }, }, "catalogs": { + "alchemy": { + "@cloudflare/workers-types": "4.20260603.1", + "alchemy": "2.0.0-beta.80", + }, "effect": { "@effect/ai-openrouter": "4.0.1", "@effect/atom-react": "4.0.1", @@ -608,6 +657,14 @@ "packages": { "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], + "@alchemy.run/cloudflare-runtime": ["@alchemy.run/cloudflare-runtime@2.0.0-beta.80", "", { "dependencies": { "@alchemy.run/node-utils": "2.0.0-beta.80", "@cloudflare/unenv-preset": "^2.16.1", "@puppeteer/browsers": "^3.2.0", "capnp-es": "^0.0.16", "magic-string": "^0.30.21", "mime": "^4.0.7", "sharp": "^0.35.3", "unenv": "^2.0.0-rc.24", "workerd": "1.20260918.1", "yauzl": "^3.4.0" }, "peerDependencies": { "@distilled.cloud/cloudflare": "1.0.0-rc.13", "@effect/platform-bun": "^4.0.0", "@effect/platform-node": "^4.0.0", "effect": "^4.0.0", "rolldown": "1.2.5", "vite": "^7.0.0 || ^8.0.0" }, "optionalPeers": ["@effect/platform-bun", "@effect/platform-node", "rolldown", "vite"] }, "sha512-vrMDVAQ+LlzFGkgfIbi/65iwskdSxxWYhJX03sFL72mvkGxvb8RQSKrbxLGHhMz0QSZh796dOff1z9zOwxsIbA=="], + + "@alchemy.run/floci": ["@alchemy.run/floci@2.0.0-beta.80", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-gtUXFitVY9pSQQYCDtSVyjfd5YOKY3Wleg3WCG5yWno7/l7xBo4kfOMbM63tzWnmka9LewVSITbMfwnC4D/73A=="], + + "@alchemy.run/node-utils": ["@alchemy.run/node-utils@2.0.0-beta.80", "", { "dependencies": { "chokidar": "^5.0.0", "rolldown": "1.2.5" } }, "sha512-uOhVDQZTjwh1AU4mrk0wB5rQaziTwSrVMfqwFP8wALfU//nveu4goOWZimHtqytXiybENh+iEbALH25EFAkc7A=="], + + "@alchemy.run/sigil": ["@alchemy.run/sigil@0.1.0-alpha.1", "", { "peerDependencies": { "@types/react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-RtHELG2w+GluVewRsuKm66idtPzC+4STptMr/61X1Vy5uBkDzcCzjzV8G6jUa4iBv6Il0hHV7UL6wvv//9nD/Q=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@apideck/better-ajv-errors": ["@apideck/better-ajv-errors@0.3.6", "", { "dependencies": { "json-schema": "^0.4.0", "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA=="], @@ -856,12 +913,22 @@ "@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w=="], + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="], + + "@chevrotain/gast": ["@chevrotain/gast@10.5.0", "", { "dependencies": { "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A=="], + + "@chevrotain/types": ["@chevrotain/types@10.5.0", "", {}, "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A=="], + + "@chevrotain/utils": ["@chevrotain/utils@10.5.0", "", {}, "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ=="], + "@clerk/backend": ["@clerk/backend@3.3.0", "", { "dependencies": { "@clerk/shared": "^4.8.3", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-6KuOaIig4CQPSn23I7xExkGKKi2rE2PEH3mvhnA41fktv3LqtPSifRb0xjmJEAwSZwJ8QY6uzk/jDccNof2eUA=="], "@clerk/react": ["@clerk/react@6.4.3", "", { "dependencies": { "@clerk/shared": "^4.8.3", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "sha512-CLZJZ9GOTEqrTrOpq3SuvKhjcQ69Mc5vCrz5Xg77J+7ZX+BgsOLmmqQhMxZ0THhTIoOu0pZ3UazlZEAsv3fSbQ=="], "@clerk/shared": ["@clerk/shared@4.8.3", "", { "dependencies": { "@tanstack/query-core": "5.90.16", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-HZViZBCTfOR2OreSBDMXcIRPgYiiYCE+GCCPrpjq/ZPcA6OsGiRCIQgUoGgGdAoFgr6Hk0TT00hnVK7g0qRKqQ=="], + "@cloudflare/containers": ["@cloudflare/containers@0.3.7", "", {}, "sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], "@cloudflare/realtimekit": ["@cloudflare/realtimekit@1.2.4", "", { "dependencies": { "@protobuf-ts/runtime": "^2.7.0", "bowser": "^2.11.0", "sdp-transform": "^2.14.1", "uuid": "^8.3.2", "worker-timers": "7.0.60" } }, "sha512-qsihYdepdtCmCnPDfAp+ceZBjn4ZzGxsvCk7t6vEjEwwjDsDAAj4AkbOyfu4nWorfaWx4dbPkWxynoYy/70gMQ=="], @@ -886,6 +953,8 @@ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260312.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5dBrlSK+nMsZy5bYQpj8t9iiQNvCRlkm9GGvswJa9vVU/1BNO4BhJMlqOLWT24EmFyApZ+kaBiPJMV8847NDTg=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260603.1", "", {}, "sha512-TLeVHoBbcYv35S5TdRWUoj3IJ56BhHtrsuci+O7ithU8yz7ttNdCk6rAl1QUSGNVEWSIp54bWOuV/xmX1zu79g=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], @@ -900,6 +969,38 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@distilled.cloud/acme": ["@distilled.cloud/acme@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-3rmzH125jQxk0zpCvXGNEybSaqpAolyCX+8LWDDrXf+wIKs67GflkJyQLjOOh0lKARe29hdUZGgqEbbvpp0LNA=="], + + "@distilled.cloud/aws": ["@distilled.cloud/aws@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13", "@smithy/types": "^4.17.2" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-6ZGcp0LkniB0I2l8Gr7vKRQgTZTch0/07iuvuMZv9Nu+rWwNuZG9VHTUdlIoarWgCHFUNN8yvSYBGOjaeN99tQ=="], + + "@distilled.cloud/axiom": ["@distilled.cloud/axiom@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-aRoJtvfXgWi5dtfjuJvDwnArRKvv2MPYHCxYeZcxZbqe62IaQA5yu/khft7TJvxeDyJoSDnVXZu3RyJjNmBA4g=="], + + "@distilled.cloud/cloudflare": ["@distilled.cloud/cloudflare@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-Py5FoA6azyYExDb91VaeC+9x+ravOBSRhXeUmpcQjCtgqmOdiXgc2YZGMMPI68DF+kMuHRVIrR+2uRZNDOhYjw=="], + + "@distilled.cloud/core": ["@distilled.cloud/core@1.0.0-rc.13", "", { "dependencies": { "graphql": "16.11.0" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-RYyrxW+F5zrTuu1UWV/JoycholBCIkXIusYJdDniDwCdmbjdKGl07KlkkeeY774uBawNGf+pt1ESNGeLVnNHsg=="], + + "@distilled.cloud/doppler": ["@distilled.cloud/doppler@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-GgOEUCc4Bx0C+lYNfZ4OqaA3ULvqDUQTJC8gobSh1L0jfrWclv/kRcSr43v4IXyStld+saUvDfDZgeGzlE74OA=="], + + "@distilled.cloud/fly-io": ["@distilled.cloud/fly-io@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-Ipp+9nvs+PzqwDPhZq2/BEHNUeWzXKhrvjfT/eBLFHo/QpH0HgdMH8MSQe6/LGszZoormwA6ZrTv2wxaFO7ZiQ=="], + + "@distilled.cloud/gcp": ["@distilled.cloud/gcp@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-un8jqil9ejt96oN+OfEDUsbrEOpC6Ut4B/73DqKDttvt0Btal1OeBNTTQsftVCpeEzXAvkYKGRIT2AeEu1J5jA=="], + + "@distilled.cloud/hetzner": ["@distilled.cloud/hetzner@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-FvqofgMypNkeZbnjGjWMZqvqGd9GxcMzdbi/6JrbcPaHMJG+CCBGt/mAa7mLlEovzwVaGCsBnOXJ6rP5pZjB4Q=="], + + "@distilled.cloud/infisical": ["@distilled.cloud/infisical@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-Zz8ksA/Y8UUUnWag2UBxB5WYA6wLqOS78ugWJ3cOr9Ot+4bC0NJYWanq35uFInozLXWhun84USU2wOI+mdwQUQ=="], + + "@distilled.cloud/neon": ["@distilled.cloud/neon@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-2JvbBFj1kBfkDVPnWcJZ7TJ1cJwWBtScAY7SNJ2qNBUuAK1Wf2kjJXU0/j16eCJ48BQigPArQFWL51wVu/EUQQ=="], + + "@distilled.cloud/planetscale": ["@distilled.cloud/planetscale@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-AM97YGRnDfZdqsxFgQMQ83p7ZlRqxS6/foTTJA5P590UMbbOyrQyFyd33BD0agul8a3ngZde3Oej6v7BN55aWw=="], + + "@distilled.cloud/prisma": ["@distilled.cloud/prisma@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-+UmHSp9JV8XQl5t5tTdQBY+dWZ9abrEoc7leYEctA0Ar1TrKZEl2EaeQDoumrg4RyGaacRlirEbxaDFI4SOPtQ=="], + + "@distilled.cloud/railway": ["@distilled.cloud/railway@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-yXalRS3GFLo3hWM0HXptqudi23NVCM4FRD20db2/Ob9FFcNrMJik1fopv+74HOiV+BuxfkCP609KsEK6LVdB/g=="], + + "@distilled.cloud/stripe": ["@distilled.cloud/stripe@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-jLTJ1mg4U39lHZb9EGYBJLR91yB3LSG6fVxix+r1BbqaTovWzE+NuJ7fu9fRSeax9jRAqsGLLxe9IxJbOtwQnQ=="], + + "@distilled.cloud/zerossl": ["@distilled.cloud/zerossl@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-dnxhg39lZjpTdZbHCVYPoUC9E2r8eSHbqiYDhSq2EurVPJcc9YRXlxZR1bP1nniSp078XV8GA+aUECt/Z56aQg=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@effect/ai-openrouter": ["@effect/ai-openrouter@4.0.1", "", { "peerDependencies": { "effect": "^4.0.1" } }, "sha512-l9vVp3ag8/N+OmPD/SP86MKuJrLxpZ5U2ItM6jPO/Al8njQZ4uEWRQZ72byVH7qumaWsN7cSd7wTCR8C9epBAw=="], @@ -920,12 +1021,22 @@ "@effect/rpc": ["@effect/rpc@0.73.2", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.5", "effect": "^3.19.18" } }, "sha512-td7LHDgBOYKg+VgGWEelD8rSAmvjXz7am17vfxZROX5qIYuvH7drL/z4p5xQFadhHZ7DYdlFpqdO9ggc77OCIw=="], + "@effect/sql-d1": ["@effect/sql-d1@4.0.1", "", { "dependencies": { "@cloudflare/workers-types": "^5.20260926.1" }, "peerDependencies": { "effect": "^4.0.1" } }, "sha512-//VXATQFso9/y9JFDIfxxctMEwDlZq6RdlKA+q8nXE1N3q24FdBM9NF8BaBHT1G2NY+CB0U0PjqUASgsmcOvXw=="], + "@effect/sql-pg": ["@effect/sql-pg@4.0.1", "", { "peerDependencies": { "effect": "^4.0.1" } }, "sha512-X5aqOxHNWqVXL+vyTQZLYDip65lm5rrZHuNkWyyr672iup1tjYAs5Dn9tHR5h1t0blpJ57j8Ng5G5AUQ8teIqA=="], + "@effect/sql-sqlite-do": ["@effect/sql-sqlite-do@4.0.1", "", { "peerDependencies": { "effect": "^4.0.1" } }, "sha512-tS+qQok3xKsycnnhvIDX+6nvlM4Y2ouvru3voOzxtDQqX96NSOMX2xReLf4Feb6GN3jnzgEEtVGDDWk3YLrpsg=="], + "@effect/vitest": ["@effect/vitest@4.0.1", "", { "peerDependencies": { "effect": "^4.0.1", "vitest": ">=5.0.0 <6.0.0" } }, "sha512-DbY9kf/018zBxRIX7rJwBnPVPzSarnUpcshm0vfYPFiDkSa7AFrXTh2BlDNUbrdrmqcIOBhVZ7GkEtxf8WiQrg=="], "@electric-sql/client": ["@electric-sql/client@1.5.28", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" }, "bin": { "intent": "bin/intent.mjs" } }, "sha512-b0cym3YWXL6PbQmEF0xDRsLt2xgXX46stvOSRAIXRPAP6dLZIHZL0o0HxeTdsZ27L3PbIg6Pw6KnyanRVGtWPA=="], + "@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="], + + "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg=="], + + "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.2.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" } }, "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A=="], + "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], @@ -1058,10 +1169,14 @@ "@hazel/effect-bun": ["@hazel/effect-bun@workspace:packages/effect-bun"], + "@hazel/effect-cloudflare": ["@hazel/effect-cloudflare@workspace:packages/effect-cloudflare"], + "@hazel/effect-electric-db-collection": ["@hazel/effect-electric-db-collection@workspace:libs/effect-electric-db-collection"], "@hazel/electric-proxy": ["@hazel/electric-proxy@workspace:apps/electric-proxy"], + "@hazel/infra": ["@hazel/infra@workspace:packages/infra"], + "@hazel/integrations": ["@hazel/integrations@workspace:packages/integrations"], "@hazel/link-preview-worker": ["@hazel/link-preview-worker@workspace:apps/link-preview-worker"], @@ -1092,6 +1207,8 @@ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], @@ -1130,6 +1247,8 @@ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], @@ -1186,6 +1305,32 @@ "@legendapp/list": ["@legendapp/list@3.0.0-beta.41", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "sha512-m/wxzotZDE2z9Wh/l8LkLpKNydbc+nOgXm2Ao6yxYv3Rml8go+IdaPzHz4PsLplzWdSS3fOz5omKIxnBC6B9Cw=="], + "@libsql/client": ["@libsql/client@0.17.4", "", { "dependencies": { "@libsql/core": "^0.17.4", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw=="], + + "@libsql/core": ["@libsql/core@0.17.4", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-LqF9gIvnJ38nmAH1y/ChizHqDO/MO1wLgA96XrraulEEbqXxLjleSH92YWTolbuJKgPUmGu4aJk9W3UnAcxLOQ=="], + + "@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.29", "", { "os": "darwin", "cpu": "arm64" }, "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A=="], + + "@libsql/darwin-x64": ["@libsql/darwin-x64@0.5.29", "", { "os": "darwin", "cpu": "x64" }, "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ=="], + + "@libsql/hrana-client": ["@libsql/hrana-client@0.10.0", "", { "dependencies": { "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5" } }, "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw=="], + + "@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="], + + "@libsql/linux-arm-gnueabihf": ["@libsql/linux-arm-gnueabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ=="], + + "@libsql/linux-arm-musleabihf": ["@libsql/linux-arm-musleabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg=="], + + "@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w=="], + + "@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg=="], + + "@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg=="], + + "@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w=="], + + "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="], + "@linear/sdk": ["@linear/sdk@73.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0" } }, "sha512-+7bMcDsDafS+468dd0JE0a0tbiBJtL253wKdKUIjGqkG6zm6Mr+WW+cvjo2Bl7T8Lldldq2cIA2tUT9ZjzJDkQ=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], @@ -1194,6 +1339,8 @@ "@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="], + "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], @@ -1210,12 +1357,46 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], + + "@neon/functions": ["@neon/functions@0.11.0", "", { "peerDependencies": { "hono": "^4.7.8" }, "optionalPeers": ["hono"] }, "sha512-xyIEk9aULR2nBTzyTgxljkM8aS+Ppe9oVw/t5mdqYOlY/mLFppHSZHq8hiWVDWy6Ca8oLd3loTtfVz+cZQQjsA=="], + "@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="], "@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], "@number-flow/react": ["@number-flow/react@0.6.0", "", { "dependencies": { "esm-env": "^1.1.4", "number-flow": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-77Yfc9+zkV2UDSP8phhZzxJGuwxi/Tt1TikmipL+1r3e9GFKEYDZ1XwInj67NoSt3OnOB0KLvvcl3lfPZgBHVQ=="], + "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "@octokit/core": ["@octokit/core@7.0.8", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.5", "@octokit/request": "^10.0.16", "@octokit/request-error": "^7.1.2", "@octokit/types": "^18.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg=="], + + "@octokit/endpoint": ["@octokit/endpoint@11.0.5", "", { "dependencies": { "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ=="], + + "@octokit/graphql": ["@octokit/graphql@9.0.5", "", { "dependencies": { "@octokit/request": "^10.0.16", "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@29.0.1", "", {}, "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg=="], + + "@octokit/openapi-webhooks-types": ["@octokit/openapi-webhooks-types@12.1.0", "", {}, "sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], + + "@octokit/request": ["@octokit/request@10.0.16", "", { "dependencies": { "@octokit/endpoint": "^11.0.5", "@octokit/request-error": "^7.1.2", "@octokit/types": "^18.0.0", "content-type": "^3.0.0", "json-with-bigint": "^3.5.12", "universal-user-agent": "^7.0.2" } }, "sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ=="], + + "@octokit/request-error": ["@octokit/request-error@7.1.2", "", { "dependencies": { "@octokit/types": "^18.0.0" } }, "sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg=="], + + "@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], + + "@octokit/types": ["@octokit/types@18.0.0", "", { "dependencies": { "@octokit/openapi-types": "^29.0.1" } }, "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA=="], + + "@octokit/webhooks": ["@octokit/webhooks@14.2.0", "", { "dependencies": { "@octokit/openapi-webhooks-types": "12.1.0", "@octokit/request-error": "^7.0.0", "@octokit/webhooks-methods": "^6.0.0" } }, "sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw=="], + + "@octokit/webhooks-methods": ["@octokit/webhooks-methods@6.0.0", "", {}, "sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ=="], + "@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="], "@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="], @@ -1256,90 +1437,10 @@ "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], - "@oxc-minify/binding-android-arm-eabi": ["@oxc-minify/binding-android-arm-eabi@0.110.0", "", { "os": "android", "cpu": "arm" }, "sha512-43fMTO8/5bMlqfOiNSZNKUzIqeLIYuB9Hr1Ohyf58B1wU11S2dPGibTXOGNaWsfgHy99eeZ1bSgeIHy/fEYqbw=="], - - "@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.110.0", "", { "os": "android", "cpu": "arm64" }, "sha512-5oQrnn9eK/ccOp80PTrNj0Vq893NPNNRryjGpOIVsYNgWFuoGCfpnKg68oEFcN8bArizYAqw4nvgHljEnar69w=="], - - "@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.110.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dqBDgTG9tF2z2lrZp9E8wU+Godz1i8gCGSei2eFKS2hRploBOD5dmOLp1j4IMornkPvSQmbwB3uSjPq7fjx4EA=="], - - "@oxc-minify/binding-darwin-x64": ["@oxc-minify/binding-darwin-x64@0.110.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U0AqabqaooDOpYmeeOye8wClv8PSScELXgOfYqyqgrwH9J9KrpCE1jL8Rlqgz68QbL4mPw3V6sKiiHssI4CLeQ=="], - - "@oxc-minify/binding-freebsd-x64": ["@oxc-minify/binding-freebsd-x64@0.110.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-H0w8o/Wo1072WSdLfhwwrpFpwZnPpjQODlHuRYkTfsSSSJbTxQtjJd4uxk7YJsRv5RQp69y0I7zvdH6f8Xueyw=="], - - "@oxc-minify/binding-linux-arm-gnueabihf": ["@oxc-minify/binding-linux-arm-gnueabihf@0.110.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qd6sW0AvEVYZhbVVMGtmKZw3b1zDYGIW+54Uh42moWRAj6i4Jhk/LGr6r9YNZpOINeuvZfkFuEeDD/jbu7xPUA=="], - - "@oxc-minify/binding-linux-arm-musleabihf": ["@oxc-minify/binding-linux-arm-musleabihf@0.110.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7WXP0aXMrWSn0ScppUBi3jf68ebfBG0eri8kxLmBOVSBj6jw1repzkHMITJMBeLr5d0tT/51qFEptiAk2EP2iA=="], - - "@oxc-minify/binding-linux-arm64-gnu": ["@oxc-minify/binding-linux-arm64-gnu@0.110.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-LYfADrq5x1W5gs+u9OIbMbDQNYkAECTXX0ufnAuf3oGmO51rF98kGFR5qJqC/6/csokDyT3wwTpxhE0TkcF/Og=="], - - "@oxc-minify/binding-linux-arm64-musl": ["@oxc-minify/binding-linux-arm64-musl@0.110.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-53GjCVY8kvymk9P6qNDh6zyblcehF5QHstq9QgCjv13ONGRnSHjeds0PxIwiihD7h295bxsWs84DN39syLPH4Q=="], - - "@oxc-minify/binding-linux-ppc64-gnu": ["@oxc-minify/binding-linux-ppc64-gnu@0.110.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-li8XcN81dxbJDMBESnTgGhoiAQ+CNIdM0QGscZ4duVPjCry1RpX+5FJySFbGqG3pk4s9ZzlL/vtQtbRzZIZOzg=="], - - "@oxc-minify/binding-linux-riscv64-gnu": ["@oxc-minify/binding-linux-riscv64-gnu@0.110.0", "", { "os": "linux", "cpu": "none" }, "sha512-SweKfsnLKShu6UFV8mwuj1d1wmlNoL/FlAxPUzwjEBgwiT2HQkY24KnjBH+TIA+//1O83kzmWKvvs4OuEhdIEQ=="], - - "@oxc-minify/binding-linux-riscv64-musl": ["@oxc-minify/binding-linux-riscv64-musl@0.110.0", "", { "os": "linux", "cpu": "none" }, "sha512-oH8G4aFMP8XyTsEpdANC5PQyHgSeGlopHZuW1rpyYcaErg5YaK0vXjQ4EM5HVvPm+feBV24JjxgakTnZoF3aOQ=="], - - "@oxc-minify/binding-linux-s390x-gnu": ["@oxc-minify/binding-linux-s390x-gnu@0.110.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-W9na+Vza7XVUlpf8wMt4QBfH35KeTENEmnpPUq3NSlbQHz8lSlSvhAafvo43NcKvHAXV3ckD/mUf2VkqSdbklg=="], - - "@oxc-minify/binding-linux-x64-gnu": ["@oxc-minify/binding-linux-x64-gnu@0.110.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XJdA4mmmXOjJxSRgNJXsDP7Xe8h3gQhmb56hUcCrvq5d+h5UcEi2pR8rxsdIrS8QmkLuBA3eHkGK8E27D7DTgQ=="], - - "@oxc-minify/binding-linux-x64-musl": ["@oxc-minify/binding-linux-x64-musl@0.110.0", "", { "os": "linux", "cpu": "x64" }, "sha512-QqzvALuOTtSckI8x467R4GNArzYDb/yEh6aNzLoeaY1O7vfT7SPDwlOEcchaTznutpeS9Dy8gUS/AfqtUHaufw=="], - - "@oxc-minify/binding-openharmony-arm64": ["@oxc-minify/binding-openharmony-arm64@0.110.0", "", { "os": "none", "cpu": "arm64" }, "sha512-gAMssLs2Q3+uhLZxanh1DF+27Kaug3cf4PXb9AB7XK81DR+LVcKySXaoGYoOs20Co0fFSphd6rRzKge2qDK3dA=="], - - "@oxc-minify/binding-wasm32-wasi": ["@oxc-minify/binding-wasm32-wasi@0.110.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-7Wqi5Zjl022bs2zXq+ICdalDPeDuCH/Nhbi8q2isLihAonMVIT0YH2hqqnNEylRNGYck+FJ6gRZwMpGCgrNxPg=="], - - "@oxc-minify/binding-win32-arm64-msvc": ["@oxc-minify/binding-win32-arm64-msvc@0.110.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZPx+0Tj4dqn41ecyoGotlvekQKy6JxJCixn9Rw7h/dafZ3eDuBcEVh3c2ZoldXXsyMIt5ywI8IWzFZsjNedd5Q=="], - - "@oxc-minify/binding-win32-ia32-msvc": ["@oxc-minify/binding-win32-ia32-msvc@0.110.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-H0Oyd3RWBfpEyvJIrFK94RYiY7KKSQl11Ym7LMDwLEagelIAfRCkt1amHZhFa/S3ZRoaOJFXzEw4YKeSsjVFsg=="], - - "@oxc-minify/binding-win32-x64-msvc": ["@oxc-minify/binding-win32-x64-msvc@0.110.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hr3nK90+qXKJ2kepXwFIcNfQQIOBecB4FFCyaMMypthoEEhVP08heRynj4eSXZ8NL9hLjs3fQzH8PJXfpznRnQ=="], - "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="], "@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], - "@oxc-transform/binding-android-arm-eabi": ["@oxc-transform/binding-android-arm-eabi@0.110.0", "", { "os": "android", "cpu": "arm" }, "sha512-sE9dxvqqAax1YYJ3t7j+h5ZSI9jl6dYuDfngl6ieZUrIy5P89/8JKVgAzgp8o3wQSo7ndpJvYsi1K4ZqrmbP7w=="], - - "@oxc-transform/binding-android-arm64": ["@oxc-transform/binding-android-arm64@0.110.0", "", { "os": "android", "cpu": "arm64" }, "sha512-nqtbP4aMCtsCZ6qpHlHaQoWVHSBtlKzwaAgwEOvR+9DWqHjk31BHvpGiDXlMeed6CVNpl3lCbWgygb3RcSjcfw=="], - - "@oxc-transform/binding-darwin-arm64": ["@oxc-transform/binding-darwin-arm64@0.110.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oeSeHnL4Z4cMXtc8V0/rwoVn0dgwlS9q0j6LcHn9dIhtFEdp3W0iSBF8YmMQA+E7sILeLDjsHmHE4Kp0sOScXw=="], - - "@oxc-transform/binding-darwin-x64": ["@oxc-transform/binding-darwin-x64@0.110.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-nL9K5x7OuZydobAGPylsEW9d4APs2qEkIBLMgQPA+kY8dtVD3IR87QsTbs4l4DBQYyun/+ay6qVCDlxqxdX2Jg=="], - - "@oxc-transform/binding-freebsd-x64": ["@oxc-transform/binding-freebsd-x64@0.110.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GS29zXXirDQhZEUq8xKJ1azAWMuUy3Ih3W5Bc5ddk12LRthO5wRLFcKIyeHpAXCoXymQ+LmxbMtbPf84GPxouw=="], - - "@oxc-transform/binding-linux-arm-gnueabihf": ["@oxc-transform/binding-linux-arm-gnueabihf@0.110.0", "", { "os": "linux", "cpu": "arm" }, "sha512-glzDHak8ISyZJemCUi7RCvzNSl+MQ1ly9RceT2qRufhUsvNZ4C/2QLJ1HJwd2N6E88bO4laYn+RofdRzNnGGEA=="], - - "@oxc-transform/binding-linux-arm-musleabihf": ["@oxc-transform/binding-linux-arm-musleabihf@0.110.0", "", { "os": "linux", "cpu": "arm" }, "sha512-8JThvgJ2FRoTVfbp7e4wqeZqCZbtudM06SfZmNzND9kPNu/LVYygIR+72RWs+xm4bWkuYHg/islo/boNPtMT5Q=="], - - "@oxc-transform/binding-linux-arm64-gnu": ["@oxc-transform/binding-linux-arm64-gnu@0.110.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IRh21Ub/g4bkHoErZ0AUWMlWfoZaS0A6EaOVtbcY70RSYIMlrsbjiFwJCzM+b/1DD1rXbH5tsGcH7GweTbfRqg=="], - - "@oxc-transform/binding-linux-arm64-musl": ["@oxc-transform/binding-linux-arm64-musl@0.110.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-e5JN94/oy+wevk76q+LMr+2klTTcO60uXa+Wkq558Ms7mdF2TvkKFI++d/JeiuIwJLTi/BxQ4qdT5FWcsHM/ug=="], - - "@oxc-transform/binding-linux-ppc64-gnu": ["@oxc-transform/binding-linux-ppc64-gnu@0.110.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Y3/Tnnz1GvDpmv8FXBIKtdZPsdZklOEPdrL6NHrN5i2u54BOkybFaDSptgWF53wOrJlTrcmAVSE6fRKK9XCM2Q=="], - - "@oxc-transform/binding-linux-riscv64-gnu": ["@oxc-transform/binding-linux-riscv64-gnu@0.110.0", "", { "os": "linux", "cpu": "none" }, "sha512-Y0E35iA9/v9jlkNcP6tMJ+ZFOS0rLsWDqG6rU9z+X2R3fBFJBO9UARIK6ngx8upxk81y1TFR2CmBFhupfYdH6Q=="], - - "@oxc-transform/binding-linux-riscv64-musl": ["@oxc-transform/binding-linux-riscv64-musl@0.110.0", "", { "os": "linux", "cpu": "none" }, "sha512-JOUSYFfHjBUs7xp2FHmZHb8eTYD/oEu0NklS6JgUauqnoXZHiTLPLVW2o2uVCqldnabYHcomuwI2iqVFYJNhTw=="], - - "@oxc-transform/binding-linux-s390x-gnu": ["@oxc-transform/binding-linux-s390x-gnu@0.110.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-7blgoXF9D3Ngzb7eun23pNrHJpoV/TtE6LObwlZ3Nmb4oZ6Z+yMvBVaoW68NarbmvNGfZ95zrOjgm6cVETLYBA=="], - - "@oxc-transform/binding-linux-x64-gnu": ["@oxc-transform/binding-linux-x64-gnu@0.110.0", "", { "os": "linux", "cpu": "x64" }, "sha512-YQ2joGWCVDZVEU2cD/r/w49hVjDm/Qu1BvC/7zs8LvprzdLS/HyMXGF2oA0puw0b+AqgYaz3bhwKB2xexHyITQ=="], - - "@oxc-transform/binding-linux-x64-musl": ["@oxc-transform/binding-linux-x64-musl@0.110.0", "", { "os": "linux", "cpu": "x64" }, "sha512-fkjr5qE632ULmNgvFXWDR/8668WxERz3tU7TQFp6JebPBneColitjSkdx6VKNVXEoMmQnOvBIGeP5tUNT384oA=="], - - "@oxc-transform/binding-openharmony-arm64": ["@oxc-transform/binding-openharmony-arm64@0.110.0", "", { "os": "none", "cpu": "arm64" }, "sha512-HWH9Zj+lMrdSTqFRCZsvDWMz7OnMjbdGsm3xURXWfRZpuaz0bVvyuZNDQXc4FyyhRDsemICaJbU1bgeIpUJDGw=="], - - "@oxc-transform/binding-wasm32-wasi": ["@oxc-transform/binding-wasm32-wasi@0.110.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ejdxHmYfIcHDPhZUe3WklViLt9mDEJE5BzcW7+R1vc5i/5JFA8D0l7NUSsHBJ7FB8Bu9gF+5iMDm6cXGAgaghw=="], - - "@oxc-transform/binding-win32-arm64-msvc": ["@oxc-transform/binding-win32-arm64-msvc@0.110.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9VTwpXCZs7xkV+mKhQ62dVk7KLnLXtEUxNS2T4nLz3iMl1IJbA4h5oltK0JoobtiUAnbkV53QmMVGW8+Nh3bDQ=="], - - "@oxc-transform/binding-win32-ia32-msvc": ["@oxc-transform/binding-win32-ia32-msvc@0.110.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-5y0fzuNON7/F2hh2P94vANFaRPJ/3DI1hVl5rseCT8VUVqOGIjWaza0YS/D1g6t1WwycW2LWDMi2raOKoWU5GQ=="], - - "@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.110.0", "", { "os": "win32", "cpu": "x64" }, "sha512-QROrowwlrApI1fEScMknGWKM6GTM/Z2xwMnDqvSaEmzNazBsDUlE08Jasw610hFEsYAVU2K5sp/YaCa9ORdP4A=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.40.0", "", { "os": "android", "cpu": "arm" }, "sha512-S6zd5r1w/HmqR8t0CTnGjFTBLDq2QKORPwriCHxo4xFNuhmOTABGjPaNvCJJVnrKBLsohOeiDX3YqQfJPF+FXw=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.40.0", "", { "os": "android", "cpu": "arm64" }, "sha512-/mbS9UUP/5Vbl2D6osIdcYiP0oie63LKMoTyGj5hyMCK/SFkl3EhtyRAfdjPvuvHC0SXdW6ePaTKkBSq1SNcIw=="], @@ -1436,6 +1537,14 @@ "@posthog/types": ["@posthog/types@1.360.0", "", {}, "sha512-roypbiJ49V3jWlV/lzhXGf0cKLLRj69L4H4ZHW6YsITHlnjQ12cgdPhPS88Bb9nW9xZTVSGWWDjfNGsdgAxsNg=="], + "@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + + "@prisma/dev": ["@prisma/dev@0.20.0", "", { "dependencies": { "@electric-sql/pglite": "0.3.15", "@electric-sql/pglite-socket": "0.0.20", "@electric-sql/pglite-tools": "0.2.20", "@hono/node-server": "1.19.9", "@mrleebo/prisma-ast": "0.13.1", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "hono": "4.11.4", "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.2.0", "zeptomatch": "2.1.0" } }, "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ=="], + + "@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="], + + "@prisma/query-plan-executor": ["@prisma/query-plan-executor@7.2.0", "", {}, "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ=="], + "@protobuf-ts/runtime": ["@protobuf-ts/runtime@2.11.1", "", {}, "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ=="], "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], @@ -1458,6 +1567,8 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@puppeteer/browsers": ["@puppeteer/browsers@3.2.3", "", { "dependencies": { "modern-tar": "^0.8.4", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-2Bt3m6dDAJqmZehn0wiXSYyQmuhyiHHZ7FjvrVaS1W2RoFGV6eCK71UvKYkNJjkwC1Q9nobjhvTMhLQztFVINA=="], + "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], @@ -1780,6 +1891,8 @@ "@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.1.6", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-eLVFBbhOlBQKzO5lu032tOo0OEAFFp7uNcGwvB1mBFmYsm7aKBgnJl214IV39a6fRtCL2meVxiMU1GKb006zYw=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="], @@ -1890,6 +2003,8 @@ "@sindresorhus/is": ["@sindresorhus/is@7.1.1", "", {}, "sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ=="], + "@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="], "@solid-primitives/keyboard": ["@solid-primitives/keyboard@1.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9dQHTTgLBqyAI7aavtO+HnpTVJgWQA1ghBSrmLtMu1SMxLPDuLfuNr+Tk5udb4AL4Ojg7h9JrKOGEEDqsJXWJA=="], @@ -2074,6 +2189,8 @@ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/aws-lambda": ["@types/aws-lambda@8.10.164", "", {}, "sha512-XOnrazWcOd6yWPnR7DxqCBPPYciDjHq+NN8LPwwqBCJNXLyPJgKo47fZpJ113+oyAn9Zti0Cf6H4k6yPcE3WFg=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -2232,6 +2349,8 @@ "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + "alchemy": ["alchemy@2.0.0-beta.80", "", { "dependencies": { "@alchemy.run/cloudflare-runtime": "2.0.0-beta.80", "@alchemy.run/floci": "2.0.0-beta.80", "@alchemy.run/node-utils": "2.0.0-beta.80", "@alchemy.run/sigil": "0.1.0-alpha.1", "@distilled.cloud/acme": "1.0.0-rc.13", "@distilled.cloud/aws": "1.0.0-rc.13", "@distilled.cloud/axiom": "1.0.0-rc.13", "@distilled.cloud/cloudflare": "1.0.0-rc.13", "@distilled.cloud/core": "1.0.0-rc.13", "@distilled.cloud/doppler": "1.0.0-rc.13", "@distilled.cloud/fly-io": "1.0.0-rc.13", "@distilled.cloud/gcp": "1.0.0-rc.13", "@distilled.cloud/hetzner": "1.0.0-rc.13", "@distilled.cloud/infisical": "1.0.0-rc.13", "@distilled.cloud/neon": "1.0.0-rc.13", "@distilled.cloud/planetscale": "1.0.0-rc.13", "@distilled.cloud/prisma": "1.0.0-rc.13", "@distilled.cloud/railway": "1.0.0-rc.13", "@distilled.cloud/stripe": "1.0.0-rc.13", "@distilled.cloud/zerossl": "1.0.0-rc.13", "@effect/sql-d1": "^4.0.0", "@effect/sql-sqlite-do": "^4.0.0", "@libsql/client": "^0.17.0", "@neon/functions": "0.11.0", "@octokit/rest": "^22.0.1", "@octokit/webhooks": "^14.2.0", "@prisma/dev": "^0.20.0", "@types/aws-lambda": "^8.10.152", "capnweb": "^0.12.0", "fflate": "^0.8.3", "libsodium-wrappers": "^0.8.3", "pathe": "^2.0.3", "picomatch": "^4.0.4", "rolldown": "1.2.5", "tinyglobby": "^0.2.17", "yaml": "^2.9.0" }, "peerDependencies": { "@alchemy.run/frontend-frameworks": "2.0.0-beta.80", "@aws/durable-execution-sdk-js": "^2.1.0", "@effect/platform-bun": "^4.0.0", "@effect/platform-node": "^4.0.0", "@effect/sql-mysql2": "^4.0.0", "@effect/sql-pg": "^4.0.0", "@effect/vitest": "^4.0.0", "@prisma/orm-postgres": "8.0.0-rc.11", "@vercel/nft": "^1.10.2", "drizzle-kit": "1.0.0-rc.5-ab785fc", "drizzle-orm": "1.0.0-rc.5-ab785fc", "effect": "^4.0.0", "mongodb": "^6.10.0", "mysql2": "^3.24.2", "pg": "^8.22.0", "prisma": "8.0.0-rc.15", "vite": "^8.0.7", "ws": "^8.21.0" }, "optionalPeers": ["@alchemy.run/frontend-frameworks", "@aws/durable-execution-sdk-js", "@effect/platform-bun", "@effect/platform-node", "@effect/sql-mysql2", "@effect/sql-pg", "@effect/vitest", "@prisma/orm-postgres", "@vercel/nft", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "pg", "prisma", "vite", "ws"], "bin": { "alchemy": "./bin/cli.js" } }, "sha512-2y7mswypPiApxuEdp8NGMfzCGB5+9W6OmQAuePyZ2xBkgoCpEqtKAnASuvKGUZPSYNESENbamXDuI5428ewu9w=="], + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -2292,6 +2411,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "b4a": ["b4a@1.7.3", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q=="], @@ -2330,6 +2451,8 @@ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -2382,6 +2505,10 @@ "caniuse-lite": ["caniuse-lite@1.0.30001754", "", {}, "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg=="], + "capnp-es": ["capnp-es@0.0.16", "", { "peerDependencies": { "typescript": "^5.7.3 || ^6.0.0" }, "optionalPeers": ["typescript"], "bin": { "capnp-es": "dist/compiler/capnpc-js.mjs", "capnpc-js": "dist/compiler/capnpc-js.mjs", "capnpc-dts": "dist/compiler/capnpc-dts.mjs", "capnpc-ts": "dist/compiler/capnpc-ts.mjs" } }, "sha512-pqhhnRqGfDdgHa+rz4JVO9j1jnkyqsHRYrD4RBtIwxCknYSRG7Mjs+x3IRfd20u+ZGS0IhW2L7cDv9ZcIkCYhQ=="], + + "capnweb": ["capnweb@0.12.0", "", {}, "sha512-jgZ/LMtMVTi+RVlooIslH78Gg23XbDTdvcLghWx3oz17D6u5GuJARt+uhZk/wcTo+f81eeabfnow4d3zLBj+JQ=="], + "cbor-extract": ["cbor-extract@2.2.0", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.0", "@cbor-extract/cbor-extract-darwin-x64": "2.2.0", "@cbor-extract/cbor-extract-linux-arm": "2.2.0", "@cbor-extract/cbor-extract-linux-arm64": "2.2.0", "@cbor-extract/cbor-extract-linux-x64": "2.2.0", "@cbor-extract/cbor-extract-win32-x64": "2.2.0" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA=="], "cbor-x": ["cbor-x@1.6.0", "", { "optionalDependencies": { "cbor-extract": "^2.2.0" } }, "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg=="], @@ -2404,6 +2531,8 @@ "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + "chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -2462,6 +2591,8 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "content-type": ["content-type@3.1.1", "", {}, "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], @@ -2708,8 +2839,6 @@ "facehash": ["facehash@0.1.0", "", { "peerDependencies": { "@types/react": "", "next": ">=15", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@types/react", "next"] }, "sha512-tv/QVZjLvEXHssqBaJECq+kRLFwwhd017PKk8ucT7aLingL2OZ5zEqKwPMHmT9+YQO92MVFWGZQP6vxV+P5vrQ=="], - "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], @@ -2730,7 +2859,7 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], "file-extension": ["file-extension@4.0.5", "", {}, "sha512-l0rOL3aKkoi6ea7MNZe6OHgqYYpn48Qfflr8Pe9G9JPPTx5A+sfboK91ZufzIs59/lPqh351l0eb6iKU9J5oGg=="], @@ -2792,6 +2921,8 @@ "get-port": ["get-port@7.1.0", "", {}, "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw=="], + "get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], @@ -2816,9 +2947,13 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "grammex": ["grammex@3.1.13", "", {}, "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg=="], + + "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], + "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], - "h3": ["h3@2.0.1-rc.11", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw=="], + "h3": ["h3@1.15.5", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg=="], "h3-v2": ["h3@2.0.1-rc.7", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.0" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qbrRu1OLXmUYnysWOCVrYhtC/m8ZuXu/zCbo3U/KyphJxbPFiC76jHYwVrmEcss9uNAHO5BoUguQ46yEpgI2PA=="], @@ -2892,6 +3027,8 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "ico-endec": ["ico-endec@0.1.6", "", {}, "sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ=="], @@ -3058,6 +3195,8 @@ "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "js-base64": ["js-base64@3.9.4", "", {}, "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q=="], + "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], @@ -3072,6 +3211,8 @@ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-with-bigint": ["json-with-bigint@3.5.12", "", {}, "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], @@ -3094,6 +3235,12 @@ "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + "libsodium": ["libsodium@0.8.4", "", {}, "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw=="], + + "libsodium-wrappers": ["libsodium-wrappers@0.8.4", "", { "dependencies": { "libsodium": "^0.8.0" } }, "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw=="], + + "libsql": ["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" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -3118,6 +3265,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + "linear-bot": ["linear-bot@workspace:bots/linear-bot"], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], @@ -3140,7 +3289,7 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], "lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="], @@ -3290,7 +3439,7 @@ "microsoft-capitalize": ["microsoft-capitalize@1.0.5", "", {}, "sha512-iqDMU9J643BHg8Zp7EMZNLTp6Pgs2f1S2SMnCW2VlUqMs17xCZ5vwVjalBJEGVcUfG+/1ePqeEGcMW3VfzHK5A=="], - "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], "miniflare": ["miniflare@4.20260312.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260312.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-pieP2rfXynPT6VRINYaiHe/tfMJ4c5OIhqRlIdLF6iZ9g5xgpEmvimvIgMpgAdDJuFlrLcwDUi8MfAo2R6dt/w=="], @@ -3314,6 +3463,8 @@ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "modern-tar": ["modern-tar@0.8.5", "", {}, "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA=="], + "motion": ["motion@12.35.2", "", { "dependencies": { "framer-motion": "^12.35.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-8zCi1DkNyU6a/tgEHn/GnnXZDcaMpDHbDOGORY1Rg/6lcNMSOuvwDB3i4hMSOvxqMWArc/vrGaw/Xek1OP69/A=="], "motion-dom": ["motion-dom@12.35.2", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg=="], @@ -3348,10 +3499,6 @@ "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], - "nf3": ["nf3@0.3.7", "", {}, "sha512-wL73kyZbBoeTWlvQWQ0gQDZnqp+aNlUN5YIqsc3fv5V/06LAlwrwt+G7TpugFLJIai0AhrmnKJ2kgW0xprj+yQ=="], - - "nitro": ["nitro@3.0.1-alpha.2", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.3", "db0": "^0.3.4", "h3": "^2.0.1-rc.11", "jiti": "^2.6.1", "nf3": "^0.3.5", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.110.0", "oxc-transform": "^0.110.0", "srvx": "^0.10.1", "undici": "^7.18.2", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.5" }, "peerDependencies": { "rolldown": ">=1.0.0-beta.0", "rollup": "^4", "vite": "^7 || ^8 || >=8.0.0-0", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-YviDY5J/trS821qQ1fpJtpXWIdPYiOizC/meHavlm1Hfuhx//H+Egd1+4C5SegJRgtWMnRPW9n//6Woaw81cTQ=="], - "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], @@ -3386,7 +3533,7 @@ "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], - "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], @@ -3406,10 +3553,6 @@ "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], - "oxc-minify": ["oxc-minify@0.110.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm-eabi": "0.110.0", "@oxc-minify/binding-android-arm64": "0.110.0", "@oxc-minify/binding-darwin-arm64": "0.110.0", "@oxc-minify/binding-darwin-x64": "0.110.0", "@oxc-minify/binding-freebsd-x64": "0.110.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.110.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.110.0", "@oxc-minify/binding-linux-arm64-gnu": "0.110.0", "@oxc-minify/binding-linux-arm64-musl": "0.110.0", "@oxc-minify/binding-linux-ppc64-gnu": "0.110.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.110.0", "@oxc-minify/binding-linux-riscv64-musl": "0.110.0", "@oxc-minify/binding-linux-s390x-gnu": "0.110.0", "@oxc-minify/binding-linux-x64-gnu": "0.110.0", "@oxc-minify/binding-linux-x64-musl": "0.110.0", "@oxc-minify/binding-openharmony-arm64": "0.110.0", "@oxc-minify/binding-wasm32-wasi": "0.110.0", "@oxc-minify/binding-win32-arm64-msvc": "0.110.0", "@oxc-minify/binding-win32-ia32-msvc": "0.110.0", "@oxc-minify/binding-win32-x64-msvc": "0.110.0" } }, "sha512-KWGTzPo83QmGrXC4ml83PM9HDwUPtZFfasiclUvTV4i3/0j7xRRqINVkrL77CbQnoWura3CMxkRofjQKVDuhBw=="], - - "oxc-transform": ["oxc-transform@0.110.0", "", { "optionalDependencies": { "@oxc-transform/binding-android-arm-eabi": "0.110.0", "@oxc-transform/binding-android-arm64": "0.110.0", "@oxc-transform/binding-darwin-arm64": "0.110.0", "@oxc-transform/binding-darwin-x64": "0.110.0", "@oxc-transform/binding-freebsd-x64": "0.110.0", "@oxc-transform/binding-linux-arm-gnueabihf": "0.110.0", "@oxc-transform/binding-linux-arm-musleabihf": "0.110.0", "@oxc-transform/binding-linux-arm64-gnu": "0.110.0", "@oxc-transform/binding-linux-arm64-musl": "0.110.0", "@oxc-transform/binding-linux-ppc64-gnu": "0.110.0", "@oxc-transform/binding-linux-riscv64-gnu": "0.110.0", "@oxc-transform/binding-linux-riscv64-musl": "0.110.0", "@oxc-transform/binding-linux-s390x-gnu": "0.110.0", "@oxc-transform/binding-linux-x64-gnu": "0.110.0", "@oxc-transform/binding-linux-x64-musl": "0.110.0", "@oxc-transform/binding-openharmony-arm64": "0.110.0", "@oxc-transform/binding-wasm32-wasi": "0.110.0", "@oxc-transform/binding-win32-arm64-msvc": "0.110.0", "@oxc-transform/binding-win32-ia32-msvc": "0.110.0", "@oxc-transform/binding-win32-x64-msvc": "0.110.0" } }, "sha512-/fymQNzzUoKZweH0nC5yvbI2eR0yWYusT9TEKDYVgOgYrf9Qmdez9lUFyvxKR9ycx+PTHi/reIOzqf3wkShQsw=="], - "oxfmt": ["oxfmt@0.40.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.40.0", "@oxfmt/binding-android-arm64": "0.40.0", "@oxfmt/binding-darwin-arm64": "0.40.0", "@oxfmt/binding-darwin-x64": "0.40.0", "@oxfmt/binding-freebsd-x64": "0.40.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", "@oxfmt/binding-linux-arm64-gnu": "0.40.0", "@oxfmt/binding-linux-arm64-musl": "0.40.0", "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-musl": "0.40.0", "@oxfmt/binding-linux-s390x-gnu": "0.40.0", "@oxfmt/binding-linux-x64-gnu": "0.40.0", "@oxfmt/binding-linux-x64-musl": "0.40.0", "@oxfmt/binding-openharmony-arm64": "0.40.0", "@oxfmt/binding-win32-arm64-msvc": "0.40.0", "@oxfmt/binding-win32-ia32-msvc": "0.40.0", "@oxfmt/binding-win32-x64-msvc": "0.40.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ=="], "oxlint": ["oxlint@1.55.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.55.0", "@oxlint/binding-android-arm64": "1.55.0", "@oxlint/binding-darwin-arm64": "1.55.0", "@oxlint/binding-darwin-x64": "1.55.0", "@oxlint/binding-freebsd-x64": "1.55.0", "@oxlint/binding-linux-arm-gnueabihf": "1.55.0", "@oxlint/binding-linux-arm-musleabihf": "1.55.0", "@oxlint/binding-linux-arm64-gnu": "1.55.0", "@oxlint/binding-linux-arm64-musl": "1.55.0", "@oxlint/binding-linux-ppc64-gnu": "1.55.0", "@oxlint/binding-linux-riscv64-gnu": "1.55.0", "@oxlint/binding-linux-riscv64-musl": "1.55.0", "@oxlint/binding-linux-s390x-gnu": "1.55.0", "@oxlint/binding-linux-x64-gnu": "1.55.0", "@oxlint/binding-linux-x64-musl": "1.55.0", "@oxlint/binding-openharmony-arm64": "1.55.0", "@oxlint/binding-win32-arm64-msvc": "1.55.0", "@oxlint/binding-win32-ia32-msvc": "1.55.0", "@oxlint/binding-win32-x64-msvc": "1.55.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg=="], @@ -3454,6 +3597,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + "pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="], "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], @@ -3524,6 +3669,8 @@ "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -3542,8 +3689,6 @@ "punycode2": ["punycode2@1.0.1", "", {}, "sha512-+TXpd9YRW4YUZZPoRHJ3DILtWwootGc2DsgvfHmklQ8It1skINAuqSdqizt5nlTaBmwrYACHkHApCXjc9gHk2Q=="], - "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], - "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], @@ -3620,6 +3765,8 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="], + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], @@ -3654,6 +3801,8 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], + "remend": ["remend@1.2.2", "", {}, "sha512-4ZJgIB9EG9fQE41mOJCRHMmnxDTKHWawQoJWZyUbZuj680wVyogu2ihnj8Edqm7vh2mo/TWHyEZpn2kqeDvS7w=="], "request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="], @@ -3806,7 +3955,7 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "srvx": ["srvx@0.10.1", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg=="], + "srvx": ["srvx@0.10.0", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-NqIsR+wQCfkvvwczBh8J8uM4wTZx41K2lLSEp/3oMp917ODVVMtW5Me4epCmQ3gH8D+0b+/t4xxkUKutyhimTA=="], "ssh-remote-port-forward": ["ssh-remote-port-forward@1.0.4", "", { "dependencies": { "@types/ssh2": "^0.5.48", "ssh2": "^1.4.0" } }, "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ=="], @@ -3916,7 +4065,7 @@ "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], @@ -4042,13 +4191,15 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "unplugin": ["unplugin@1.16.1", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w=="], "unrun": ["unrun@0.2.27", "", { "dependencies": { "rolldown": "1.0.0-rc.3" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-Mmur1UJpIbfxasLOhPRvox/QS4xBiDii71hMP7smfRthGcwFL2OAmYRgduLANOAU4LUkvVamuP+02U+c90jlrw=="], - "unstorage": ["unstorage@2.0.0-alpha.5", "", { "peerDependencies": { "@azure/app-configuration": "^1.9.0", "@azure/cosmos": "^4.7.0", "@azure/data-tables": "^13.3.1", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.29.1", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.12.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.35.6", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.8.2", "lru-cache": "^11.2.2", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-Sj8btci21Twnd6M+N+MHhjg3fVn6lAPElPmvFTe0Y/wR0WImErUdA1PzlAaUavHylJ7uDiFwlZDQKm0elG4b7g=="], + "unstorage": ["unstorage@1.17.4", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.5", "lru-cache": "^11.2.0", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw=="], "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], @@ -4066,7 +4217,9 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@12.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-USe1zesMYh4fjCA8ZH5+X5WIVD0J4V1Jksm1bFTVBX2F/cwSXt0RO5w/3UXbdLKmZX65MiWV+hwhSS8p6oBTGA=="], + "uuid": ["uuid@13.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="], + + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], "vbare": ["vbare@0.0.4", "", {}, "sha512-QsxSVw76NqYUWYPVcQmOnQPX8buIVjgn+yqldTHlWISulBTB9TJ9rnzZceDu+GZmycOtzsmuPbPN1YNxvK12fg=="], @@ -4232,7 +4385,7 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], "yaml-language-server": ["yaml-language-server@1.19.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "lodash": "4.17.21", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-9F3myNmJzUN/679jycdMxqtydPSDRAarSj3wPiF7pchEPnO9Dg07Oc+gIYLqXR4L+g+FSEVXXv2+mr54StLFOg=="], @@ -4240,6 +4393,8 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yauzl": ["yauzl@3.4.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw=="], + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], "yocto-spinner": ["yocto-spinner@0.2.3", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="], @@ -4252,6 +4407,8 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], + "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], @@ -4262,14 +4419,20 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@asamuzakjp/css-color/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "@alchemy.run/cloudflare-runtime/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ=="], - "@asamuzakjp/dom-selector/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "@alchemy.run/cloudflare-runtime/sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], + + "@alchemy.run/cloudflare-runtime/workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], + + "@alchemy.run/node-utils/rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="], "@astrojs/check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "@astrojs/language-server/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "@astrojs/markdown-remark/shiki": ["shiki@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/engine-javascript": "3.22.0", "@shikijs/engine-oniguruma": "3.22.0", "@shikijs/langs": "3.22.0", "@shikijs/themes": "3.22.0", "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g=="], "@astrojs/react/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], @@ -4394,11 +4557,11 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@effect/platform/effect": ["effect@3.19.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg=="], + "@distilled.cloud/core/graphql": ["graphql@16.11.0", "", {}, "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw=="], "@effect/platform-node-shared/ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], - "@effect/rpc/effect": ["effect@3.19.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg=="], + "@effect/sql-d1/@cloudflare/workers-types": ["@cloudflare/workers-types@5.20261005.1", "", {}, "sha512-QQwZY342US72QGHso/H1CSzn9FWe6IoLZ8TsYyoMnvRHHOnucGuKZRDCyOn43HAkkRQqfRHFw8NP1xe1A7Oj8A=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -4420,20 +4583,32 @@ "@hazel/electric-proxy/@electric-sql/client": ["@electric-sql/client@1.5.15", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" }, "bin": { "intent": "bin/intent.mjs" } }, "sha512-8C+mqZu6r68kU/jf63FLuc90M2ejyeTgB/68G0ufX4H2WepQw/NJXrIY3veE+sLrDGL+uyQB+fDStHH30fi8qg=="], + "@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], + + "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@libsql/isomorphic-ws/ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + "@mdx-js/mdx/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], "@metascraper/helpers/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "@metascraper/helpers/jsdom": ["jsdom@27.0.1", "", { "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA=="], + "@metascraper/helpers/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], "@opentelemetry/otlp-exporter-base/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], @@ -4456,6 +4631,10 @@ "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], + + "@prisma/dev/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -4464,6 +4643,8 @@ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@rivetkit/engine-runner/uuid": ["uuid@12.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-USe1zesMYh4fjCA8ZH5+X5WIVD0J4V1Jksm1bFTVBX2F/cwSXt0RO5w/3UXbdLKmZX65MiWV+hwhSS8p6oBTGA=="], + "@rollup/plugin-babel/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], "@rollup/plugin-babel/@rollup/pluginutils": ["@rollup/pluginutils@3.1.0", "", { "dependencies": { "@types/estree": "0.0.39", "estree-walker": "^1.0.1", "picomatch": "^2.2.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0" } }, "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg=="], @@ -4556,6 +4737,8 @@ "@tanstack/router-utils/diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "@tanstack/router-utils/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "@tanstack/start-client-core/@tanstack/router-core": ["@tanstack/router-core@1.144.0", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-6oVERtK9XDHCP4XojgHsdHO56ZSj11YaWjF5g/zw39LhyA6Lx+/X86AEIHO4y0BUrMQaJfcjdAQMVSAs6Vjtdg=="], "@tanstack/start-client-core/seroval": ["seroval@1.4.1", "", {}, "sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg=="], @@ -4570,7 +4753,7 @@ "@tanstack/start-plugin-core/@tanstack/router-plugin": ["@tanstack/router-plugin@1.145.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.144.0", "@tanstack/router-generator": "1.145.2", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.141.0", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.144.0", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-dOABjCE4M2KxB+f/mY71dDZduwVTpf+tCPb4NxmAqbF5Rxes24QaaIZQmiU12jte/L8zYyIA/yX9fi93xZue5Q=="], - "@tanstack/start-plugin-core/srvx": ["srvx@0.10.0", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-NqIsR+wQCfkvvwczBh8J8uM4wTZx41K2lLSEp/3oMp917ODVVMtW5Me4epCmQ3gH8D+0b+/t4xxkUKutyhimTA=="], + "@tanstack/start-plugin-core/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -4612,6 +4795,10 @@ "@vitest/coverage-istanbul/magicast": ["magicast@0.5.1", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "source-map-js": "^1.2.1" } }, "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw=="], + "alchemy/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "alchemy/rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="], + "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -4630,7 +4817,7 @@ "astro/shiki": ["shiki@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/engine-javascript": "3.22.0", "@shikijs/engine-oniguruma": "3.22.0", "@shikijs/langs": "3.22.0", "@shikijs/themes": "3.22.0", "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g=="], - "astro/unstorage": ["unstorage@1.17.4", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.5", "lru-cache": "^11.2.0", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw=="], + "astro/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "astro/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], @@ -4664,8 +4851,6 @@ "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], - "cssstyle/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], - "docker-compose/yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "docker-modem/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -4684,15 +4869,23 @@ "fumadocs-core/shiki": ["shiki@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/engine-javascript": "3.22.0", "@shikijs/engine-oniguruma": "3.22.0", "@shikijs/langs": "3.22.0", "@shikijs/themes": "3.22.0", "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g=="], + "fumadocs-core/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "fumadocs-mdx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + "fumadocs-mdx/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "fumadocs-mdx/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], "fumadocs-mdx/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "fumadocs-ui/lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="], - "h3-v2/srvx": ["srvx@0.10.0", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-NqIsR+wQCfkvvwczBh8J8uM4wTZx41K2lLSEp/3oMp917ODVVMtW5Me4epCmQ3gH8D+0b+/t4xxkUKutyhimTA=="], + "h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], + + "h3/crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], + + "h3/ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -4716,6 +4909,8 @@ "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], + "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "magicast/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], @@ -4738,10 +4933,12 @@ "msw/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - "nitro/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], + "node-gyp/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "ofetch/ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "openapi3-ts/yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -4752,6 +4949,10 @@ "parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + + "posthog-js/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], + "promise-retry/retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], @@ -4766,6 +4967,8 @@ "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "rivetkit/uuid": ["uuid@12.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-USe1zesMYh4fjCA8ZH5+X5WIVD0J4V1Jksm1bFTVBX2F/cwSXt0RO5w/3UXbdLKmZX65MiWV+hwhSS8p6oBTGA=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], "rolldown-plugin-dts/@babel/generator": ["@babel/generator@8.0.0-rc.1", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.1", "@babel/types": "^8.0.0-rc.1", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-3ypWOOiC4AYHKr8vYRVtWtWmyvcoItHtVqF8paFax+ydpmUdPsJpLBkBBs5ItmhdrwC3a0ZSqqFAdzls4ODP3w=="], @@ -4812,22 +5015,32 @@ "testcontainers/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "tinyglobby/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "tsdown/rolldown": ["rolldown@1.0.0-rc.3", "", { "dependencies": { "@oxc-project/types": "=0.112.0", "@rolldown/pluginutils": "1.0.0-rc.3" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-x64": "1.0.0-rc.3", "@rolldown/binding-freebsd-x64": "1.0.0-rc.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.3", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.3", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.3", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.3", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.3", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.3" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw=="], + "tsdown/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tsx/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "tsx/get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], - "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], - "unist-util-remove-position/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], "unrun/rolldown": ["rolldown@1.0.0-rc.3", "", { "dependencies": { "@oxc-project/types": "=0.112.0", "@rolldown/pluginutils": "1.0.0-rc.3" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-x64": "1.0.0-rc.3", "@rolldown/binding-freebsd-x64": "1.0.0-rc.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.3", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.3", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.3", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.3", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.3", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.3" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw=="], + "unstorage/ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + + "vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "vite-plugin-pwa/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "vite-plugin-pwa/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vitefu/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "vitest/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "web/web": ["web@0.0.2", "", {}, "sha512-DDf86rBor7Hn4uRgXbCxcOKyP8aiokvkd648k/xjKUO4hQSLvupfDC3dHCBXQ4lJFxYW62/CEWpSINbSQRr57A=="], @@ -4858,6 +5071,98 @@ "zod-to-ts/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@alchemy.run/cloudflare-runtime/sharp/@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], + + "@alchemy.run/cloudflare-runtime/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], + + "@alchemy.run/cloudflare-runtime/sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@alchemy.run/cloudflare-runtime/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260918.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ=="], + + "@alchemy.run/cloudflare-runtime/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260918.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ=="], + + "@alchemy.run/cloudflare-runtime/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260918.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw=="], + + "@alchemy.run/cloudflare-runtime/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260918.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A=="], + + "@alchemy.run/cloudflare-runtime/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], + + "@alchemy.run/node-utils/rolldown/@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.5", "", { "os": "linux", "cpu": "arm" }, "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.5", "", { "os": "none", "cpu": "arm64" }, "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw=="], + + "@alchemy.run/node-utils/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@astrojs/check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -4880,6 +5185,8 @@ "@astrojs/react/vite/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], + "@astrojs/react/vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/core/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -5120,6 +5427,10 @@ "@hazel/effect-bun/@types/bun/bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -5144,6 +5455,10 @@ "@metascraper/helpers/jsdom/whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="], + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@rollup/plugin-babel/@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], "@rollup/plugin-babel/@rollup/pluginutils/@types/estree": ["@types/estree@0.0.39", "", {}, "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw=="], @@ -5198,12 +5513,16 @@ "@tailwindcss/vite/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "@tailwindcss/vite/vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "@tanstack/devtools-vite/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@tanstack/devtools-vite/vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "@tanstack/devtools-vite/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "@tanstack/devtools-vite/vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "@tanstack/react-hotkeys/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], "@tanstack/react-start-client/@tanstack/react-router/@tanstack/history": ["@tanstack/history@1.141.0", "", {}, "sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ=="], @@ -5238,12 +5557,16 @@ "@tanstack/router-generator/@tanstack/router-utils/diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "@tanstack/router-generator/@tanstack/router-utils/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "@tanstack/router-plugin/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@tanstack/router-plugin/@tanstack/router-utils/babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], "@tanstack/router-plugin/@tanstack/router-utils/diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "@tanstack/router-plugin/@tanstack/router-utils/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "@tanstack/router-utils/@babel/core/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], @@ -5354,6 +5677,38 @@ "@vitest/coverage-istanbul/magicast/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + "alchemy/rolldown/@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], + + "alchemy/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="], + + "alchemy/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww=="], + + "alchemy/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A=="], + + "alchemy/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw=="], + + "alchemy/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.5", "", { "os": "linux", "cpu": "arm" }, "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg=="], + + "alchemy/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA=="], + + "alchemy/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA=="], + + "alchemy/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA=="], + + "alchemy/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ=="], + + "alchemy/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ=="], + + "alchemy/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA=="], + + "alchemy/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.5", "", { "os": "none", "cpu": "arm64" }, "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw=="], + + "alchemy/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw=="], + + "alchemy/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw=="], + + "alchemy/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -5380,14 +5735,6 @@ "astro/shiki/@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="], - "astro/unstorage/h3": ["h3@1.15.5", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg=="], - - "astro/unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], - - "astro/unstorage/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], - - "astro/unstorage/ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], - "astro/vite/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "babel-dead-code-elimination/@babel/core/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], @@ -5660,8 +6007,6 @@ "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], - "unifont/ofetch/ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], - "unrun/rolldown/@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="], "unrun/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.3", "", { "os": "android", "cpu": "arm64" }, "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ=="], @@ -5700,6 +6045,8 @@ "vitefu/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "vitefu/vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "workbox-build/@babel/core/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], "workbox-build/@babel/core/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], @@ -5862,6 +6209,8 @@ "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250906.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Q8Qjfs8jGVILnZL6vUpQ90q/8MTCYaGR3d1LGxZMBqte8Vr7xF3KFHPEy7tFs0j0mMjnqCYzlofmPNY+9ZaDRg=="], + "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@cloudflare/vitest-pool-workers/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "@cloudflare/vitest-pool-workers/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], @@ -6114,10 +6463,6 @@ "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], - "astro/unstorage/h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], - - "astro/unstorage/h3/crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], - "astro/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "astro/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 000000000..3b95e83e3 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,74 @@ +# Hazel infrastructure + +Everything that runs on Cloudflare is declared with [Alchemy v2](https://alchemy.run) (the Effect +version) in `alchemy.run.ts`. Each app declares its own resources: + +| App | Declared in | Runs as | +| -------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| web | `apps/web/alchemy.run.ts` | `Website.StaticSite` (Vite SPA, assets only) | +| landing | `apps/landing/alchemy.run.ts` | `Website.StaticSite` (Astro) — prd only | +| docs | `apps/docs/alchemy.run.ts` | `Website.Vite` (TanStack Start SSR) — prd only | +| link-preview | `apps/link-preview-worker/alchemy.run.ts` | async Worker + KV | +| actors | `apps/actors/alchemy.run.ts` | async Worker + RivetKit Durable Object + KV | +| electric-proxy | `apps/electric-proxy/alchemy.run.ts` | Effect Worker + KV; forwards to the `electric` Worker | +| electric | `apps/electric-proxy/resources.ts` | async Worker hosting self-hosted Electric in a Container (one instance) | +| bot-gateway | `apps/bot-gateway/alchemy.run.ts` | Effect Worker relaying bot WebSockets + `BotGateway` Durable Object per bot (SQLite event log; replaces Durable Streams + Redis leases) | +| api | `apps/backend/src/worker.ts` | Effect Worker + Durable Objects (RateLimiter, OutboxDispatcher, DiscordGateway) + KV + cron | +| database | `packages/infra/src/cloudflare/hazel-db.ts` | PlanetScale Postgres (prd) + `HAZEL_DB` Hyperdrive | + +Shared deploy-time helpers (stage parsing, the `HazelStack` context, env/secret helpers, Worker +runtime glue) live in `packages/infra`. The design and the decisions behind it are in +[`cloudflare-migration-plan.md`](./cloudflare-migration-plan.md). + +**Not on Cloudflare (yet):** `apps/cluster` (Effect Cluster workflows + crons) stays on Railway; +`bots/*` are rewritten as Workers in a follow-up. + +## Stages + +| Stage | Command | Database | Domains | +| ------------ | ----------------------------------------- | ---------------------------------------------- | -------------------------------------------- | +| `prd` | `bun run alchemy:deploy:prd` | PlanetScale `hazel`, managed by the stack | `*.hazel.sh` | +| `pr-` | `PR_NUMBER= bun run alchemy:deploy:pr` | `HAZEL_PG_URL` (shared preview DB) | `app-pr-.hazel.sh`, `api-pr-.hazel.sh` | +| `dev_` | `bun run alchemy:dev` | docker Postgres (`docker compose up postgres`) | workers.dev / localhost | + +prd keeps the wrangler-era Worker and KV names (`hazel-app`, `hazel-landing`, +`link-preview-worker`, `hazel-actors`, `hazel-actor-kv`, `link-preview-worker-link-cache`), and +`--adopt` takes them over in place on the first deploy. + +## Credentials + +Alchemy reads its own credentials from the environment (or an `alchemy profile`): + +- `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` (the "Maki Account", `189f0e30…`) +- `PLANETSCALE_API_TOKEN_ID`, `PLANETSCALE_API_TOKEN`, `PLANETSCALE_ORGANIZATION` (prd only) + +Locally, `bunx alchemy profile refresh --profile default --provider Cloudflare` re-authenticates +the default profile. + +Worker env (secrets are uploaded as Worker secrets, plain values as vars) is listed in +`apps/backend/src/worker/env.ts` and in each app's `alchemy.run.ts`. CI passes them from the +`production` / `pr-preview` GitHub Environments (`.github/workflows/deploy-*.yml`). + +## First prd deploy (cutover) + +1. **Plan first.** `bunx alchemy plan --stage prd` and check that the existing Workers, KV + namespaces and custom domains show as _adopt/update_, not _create/replace_. +2. **Database.** The first deploy creates the PlanetScale database `hazel` and its roles. + - Load the schema with `drizzle-kit push` (packages/db) against the API role's URL. + - Copy the data over in a maintenance window (`pg_dump` from the current host, then restore). + - Repoint the Railway services (backend, cluster) at PlanetScale first, so the database moves + before any compute does. +3. **Cluster (Railway).** + - Set `DATABASE_URL` / `EFFECT_DATABASE_URL` to the cluster role's connection string. + - Set `CLUSTER_API_SECRET` (the same value as the api Worker's). + - Set `OTEL_BASE_URL=https://ingest.maple.dev` and `MAPLE_INGEST_KEY`, then delete the OTel + collector service. + - Give the cluster a public domain and set the api Worker's `CLUSTER_URL` to it. +4. **api.** Deploy, then move `api.hazel.sh` to the Worker. It is a custom domain on the Worker, + so alchemy creates the DNS record; remove the Railway domain first. + - The outbox dispatcher runs on both sides during the overlap; claims use `SKIP LOCKED`, so no + event is processed twice. +5. **Discord gateway.** Set `DISCORD_GATEWAY_ENABLED=true` on the Worker _and_ `false` on the + Railway backend in the same window: Discord allows one session per bot token. +6. Keep Railway's backend running for a week as a rollback, then delete it (with Redis and the + collector). diff --git a/infra/cloudflare-migration-plan.md b/infra/cloudflare-migration-plan.md new file mode 100644 index 000000000..17d380cf5 --- /dev/null +++ b/infra/cloudflare-migration-plan.md @@ -0,0 +1,259 @@ +# Hazel → 100% Cloudflare, infra as Alchemy v2 (Effect) + +Reference implementation: `../maple` (alchemy `2.0.0-beta.80`, Effect 4). Copy API names from +`maple/node_modules/alchemy/lib`, **not** from the local `alchemy-effect` checkout (beta.36, older names). + +## Where we are today + +| Piece | Runs on | Target | +| ------------------------------------ | ------------------------------ | ----------------------------------------------------------------- | +| `apps/web` (Vite SPA) | CF Workers assets (wrangler) | `Cloudflare.Website.Vite` | +| `apps/landing` (Astro) | CF Workers assets (wrangler) | `Cloudflare.Website.StaticSite` / `Astro` | +| `apps/link-preview-worker` | CF Worker + KV (wrangler) | `Cloudflare.Worker` + `KV.Namespace` | +| `apps/actors` (RivetKit) | CF Worker + DO + KV (wrangler) | `Cloudflare.Worker` + DO + KV | +| `apps/docs` (TanStack Start/Nitro) | unclear | Worker (Nitro `cloudflare-module`) | +| `apps/electric-proxy` | Railway, Bun | Worker (template: `maple/apps/electric-sync`) | +| `apps/backend` | Railway, Bun | Worker `api` + DOs + Queues + Cron | +| `apps/cluster` (Effect Cluster) | Railway, Bun | **stays on Railway for now** (deferred; Phase 5) | +| `apps/bot-gateway` + Durable Streams | Railway, Bun/Node | `BotGateway` Durable Object per bot | +| Redis | Railway | gone: RateLimit / KV / DOs | +| OTel collector | Railway | gone: Workers observability → Maple | +| Object storage | R2 via S3 API (Bun `s3`) | native R2 binding + presign helpers | +| Postgres | external | **PlanetScale Postgres managed by alchemy** + Hyperdrive | +| Electric | Electric Cloud | **self-hosted in a `Cloudflare.Container`** behind electric-proxy | +| `bots/*` | Bun | **follow-up**: rewrite as Workers (out of scope here) | + +Target = all compute on Cloudflare, including Electric, **except `apps/cluster`**, which stays on +Railway for now. Postgres is off Cloudflare too: a PlanetScale database declared in the same alchemy +stack. + +**Decisions (2026-10-05)** + +- Postgres: PlanetScale, managed by alchemy. +- Electric: self-host it on CF Containers. +- Bots: rewrite as Workers in a separate follow-up. +- Cluster: **stays on Railway for now**; the Workflows port is deferred (see Phase 5). + +--- + +## Phase 0: Alchemy foundation (no behaviour change) + +1. **Deps**: add to root catalog `alchemy: 2.0.0-beta.80`, `@cloudflare/workers-types`, and a + `workerd` override matching maple. +2. **`packages/infra` (`@hazel/infra`)**: port from `maple/packages/infra/src`: + - `cloudflare/stage.ts`: parses `prd | pr- | dev_` and provides `resolveWorkerName`. + - `cloudflare/stack.ts`: `HazelStack` `Context.Service` (stage, domains, urls, db) plus + sibling-Worker services (`ApiWorker`, …). `Worker.ref` can't see siblings in the same deploy. + - `stageProps` / `stageNamed`, guarded by `__ALCHEMY_RUNTIME__`. + - `env.ts`: `optionalPlain`, `optionalSecret`, `requireSecretEntry` and `merge`. `Redacted` values + become Worker secrets. Each app keeps a typed env catalog. **Never call `Config.*` inside a + Worker init**, because at plan time it becomes an auto-bound secret. + - `worker-runtime.ts`: env → `ConfigProvider` (`reifyBoundConfigProvider`), so existing + `Config.String(...)` reads keep working at runtime. + - `worker-http.ts`, `cached-recoverable.ts`: `WorkerPlatformLive`, `isolateContext` and the lazy + per-isolate `HttpRouter.toHttpEffect` cache. The router is never built during init. + - `worker-telemetry.ts`, `observability.ts`: Maple OTLP from Workers. + - `hazel-db.ts`: `Cloudflare.Hyperdrive.Connection("HAZEL_DB", …)` with `caching.disabled` and a + `dev:` origin pointing at docker Postgres, plus `readHazelDbBinding(env)`. +3. **Root `alchemy.run.ts`** + `tsconfig.alchemy.json`: + - `Alchemy.Stack("hazel", { providers, state })`. State is `Cloudflare.state()`, or + `Alchemy.localState()` when `ALCHEMY_LOCAL_STATE` is set. + - The stack yields each Worker class and returns URLs as outputs. +4. **Port the already-CF apps** and delete their `wrangler.jsonc`. Deploy with `--adopt` so existing + Workers, KV and domains are adopted, not recreated: + - `web` → `Website.Vite` (SPA not-found handling; `VITE_*` in `env` gets inlined at build). + - `landing` → `Website.StaticSite` / `Website.Astro`. + - `link-preview-worker` → class-form `Cloudflare.Worker` + `KV.Namespace("LINK_CACHE")`. + - `actors` → Worker + DO namespace + KV. **Risk:** RivetKit ships its own `ActorHandler` DO class, + not an alchemy `Cloudflare.DurableObject`. Spike binding a foreign DO class (raw `bindings`) + before anything else in this phase. + - `docs` → Worker from the Nitro `cloudflare-module` output. +5. **Scripts** (mirroring maple): `alchemy:deploy:prd`, `alchemy:deploy:pr`, `alchemy:destroy:pr`. + `dev` runs `alchemy dev --stage dev_$USER --env-file .env.local` with local state. + +**Exit:** `alchemy deploy --stage prd` reproduces today's CF footprint with zero diff in behaviour. + +## Phase 1: Data plane + +- **Postgres (PlanetScale, alchemy-managed)**, following maple's `alchemy.run.ts`: + - `Planetscale.PostgresBranch("hazel-db-main", { database, name: "main", migrations: "packages/db/drizzle" })` + with `RemovalPolicy.retain()`. Drizzle migrations are applied by the stack. + - App role: `Planetscale.PostgresRole` → `role.origin` → Hyperdrive. + - Electric role: `withReplication: true`, using a separate role so the replication slot isn't + tied to the app role. + - PR stages get their own branch, and dev skips the PlanetScale provider (docker Postgres, the + maple pattern). + - **Data migration**: `pg_dump`/restore (or logical replication) from the current host into + PlanetScale during a maintenance window, then point Railway at PlanetScale first. This + decouples the DB move from the compute move. +- **Hyperdrive** in front of Postgres for every Worker. Use maple's per-request/scope `pg` dial + pattern (`DatabasePgLive.layerPg`): Worker sockets are request-bound, so no global pool. + - `packages/db` (drizzle + postgres.js) gets a `makeDb(connectionString)` that is scoped per + request. +- **R2**: + - Declare `Cloudflare.R2.Bucket("uploads")` with `RemovalPolicy.retain()`; adopt the existing + bucket and keep `cdn.hazel.sh`. + - Replace `packages/effect-bun/src/S3.ts` with an `ObjectStorage` service: an R2 binding for + read/write/delete, and alchemy's R2 presign helpers + `R2.S3Credentials` for client uploads. +- **Electric, self-hosted on CF Containers**: + - `Cloudflare.Container("Electric", { image: electricsql/electric:, instanceType: "standard-2", maxInstances: 1 })`, + reached through a Container-backed Durable Object with a single fixed id (`"electric"`). + - **Exactly one instance**: Electric owns one replication slot. Set a long/disabled `sleepAfter` + so the container never idles out; a cold start = a re-snapshot. + - Env: `DATABASE_URL` is the PlanetScale replication role and goes direct, not via Hyperdrive + (Hyperdrive can't do logical replication). Also `ELECTRIC_SECRET`, + `ELECTRIC_MANUAL_TABLE_PUBLISHING=true`, and `ELECTRIC_STORAGE_DIR` on the container's local disk. + - **Storage is ephemeral**, as in maple's ECS setup (`apps/electric/alchemy.run.ts`: "losing it + costs only a re-snapshot"). On redeploy or restart, clients get `must-refetch` and resync. + Acceptable at Hazel's size; revisit if shapes get large. + - Not publicly exposed: only `electric-proxy` talks to it (DO stub `fetch`), so `ELECTRIC_SECRET` + is defence in depth. + - Dev: `Command.Dev` / docker-compose Electric as today. + - **Spike first**: replication-slot behaviour across container restarts/deploys (slot reuse vs a + stale slot holding WAL), and the outbound TCP latency from the container to PlanetScale. + +## Phase 2: Strip Bun from shared code + +| Bun API | Replacement | +| ----------------------------------------- | --------------------------------------------------- | +| `RedisClient` (`effect-bun/Redis.ts`) | removed; per-use replacements in Phases 3–6 | +| `s3` / `S3File` | `ObjectStorage` (Phase 1) | +| `randomUUIDv7` (`routes/uploads.http.ts`) | `uuid` v7 / `@hazel/schema` helper | +| `BunHttpServer`, `BunRuntime.runMain` | `{ fetch }` from Worker init + `WorkerPlatformLive` | +| `BunSocket` DevTools in Telemetry | dev-only, behind a flag | +| `RAILWAY_GIT_COMMIT_SHA` | `COMMIT_SHA` passed as a plain var from the stack | + +`packages/effect-bun` shrinks to dev tooling (or is deleted). The runtime-agnostic packages +(`backend-core`, `domain`, `auth`, `integrations`) must typecheck under `@cloudflare/workers-types`. + +## Phase 3: electric-proxy → Worker (first real migration, lowest risk) + +- Template: `maple/apps/electric-sync/src/worker.ts`. Auth (Clerk JWT) → where-clause → forward to + Electric, with streaming passthrough. The Caddy SSE tweaks are no longer needed. +- Redis access-context cache → the Workers Cache API (`caches.default`), or KV with a short TTL. + Bot-auth cache works the same way. +- The existing e2e tests in CI run against `alchemy dev`. +- Domain: same hostname, so the web app's `VITE_ELECTRIC_URL` doesn't change. +- Upstream: the Electric container's DO stub (`env.ELECTRIC.get(idFromName("electric")).fetch`) instead of an Electric Cloud URL. `ELECTRIC_SOURCE_ID`/`SOURCE_SECRET` are dropped. + +## Phase 4: backend → `api` Worker + +- **HTTP/RPC**: `HttpApiBuilder.layer(...)` + `RpcServer` routes → `HttpRouter.toHttpEffect`, cached + per isolate (maple `apps/api/src/worker/http.ts`). The `ndjson` RPC serialization works unchanged + over fetch. +- **Redis replacements**: + - Rate limiter → `Cloudflare.RateLimit(...)` bindings. + - `RedisResultPersistence` session cache → Cache API/KV. + - `bot-commands` SSE via Redis pub/sub → `BotGateway` DO (Phase 6), which owns the fan-out. +- **`MessageOutboxDispatcher`** (an `Effect.forever` poll loop) → + - On write: enqueue to `Cloudflare.Queues.Queue("message-events")`. The consumer triggers the + workflows. + - Safety net: a cron sweep every minute for rows still `pending`, so outbox semantics are kept. +- **Discord gateway** (`dfx/gateway`, long-lived outbound WS) → singleton `DiscordGateway` Durable + Object: an outbound WebSocket kept alive while connected, an alarm-based watchdog that reconnects + and resumes, and the session/seq stored in DO SQLite. `DiscordSyncWorker` / + `ChatSyncAttributionReconciler` → cron + queue consumers. +- **Webhooks** (Clerk, GitHub, Linear) are plain fetch routes; long processing is moved to + Queues/Workflows to stay inside CPU limits. +- **Cluster calls** (`rpc/handlers/channels.ts`, `routes/webhooks.http.ts`, + `services/message-side-effect-service.ts`) keep using the HTTP `WorkflowClient`, now on + `FetchHttpClient`. `CLUSTER_URL` points at the cluster through the Tunnel/VPC binding (see Phase 5). The outbox Queue consumer + calls the cluster the same way. + +## Phase 5: Cluster stays on Railway (Workflows port deferred) + +`apps/cluster` keeps running as-is on Bun/Railway. That covers its 6 workflows, 5 crons and the +`WorkflowProxyServer`. It needs only these changes to live next to a Cloudflare-hosted backend: + +- **DB**: `DATABASE_URL` / `EFFECT_DATABASE_URL` → PlanetScale. Use a direct connection (Hyperdrive + is Workers-only), with a dedicated `Planetscale.PostgresRole` minted by the stack. +- **Reachability**: once the backend leaves Railway, it can't use `*.railway.internal`, and the + cluster's HTTP API has no auth today. + - Recommended: a **Cloudflare Tunnel** (`cloudflared` sidecar on Railway) + a Workers VPC Service + binding on `api`, both declared in alchemy (`Cloudflare.Tunnel`, `Cloudflare.VpcService`). The + cluster then stays private. + - Simpler fallback: a public URL + a shared-secret header checked by `WorkflowProxyServer`. + - Check what `VITE_CLUSTER_URL` exposes to the browser, and route it through `api` if it's more + than read-only status. +- **Telemetry**: OTLP straight to Maple, so the Railway collector can still be deleted. +- **In the stack (optional)**: alchemy beta.80 ships a `Railway` provider (`Railway.Service`, + `Variable`, `CustomDomain`). Declare the cluster service in `alchemy.run.ts` so env, secrets and + the DB role are wired from one place. Fallback: keep Railway's git deploy and pass env manually. + +**Later path (when we pick this back up).** No official Effect Cloudflare workflow engine has +shipped. Effect `4.0.1` only has `ClusterWorkflowEngine`, and effect-smol has no +`platform-cloudflare`. Alchemy's `Cloudflare.Workflow` is Effect-native: + +- `Cloudflare.Workflows.task(name, effect, { retries, timeout })` wraps `step.do`. +- `sleep` / `sleepUntil` / `waitForEvent` are Effects. + +That maps ~1:1 onto our `Activity.make` usage, and none of our workflows use `DurableDeferred` or +`DurableClock`. Crons → `Cloudflare.Workers.cron`. Re-check for an official DO-backed Effect engine +first. + +## Phase 6: bot-gateway + Durable Streams → `BotGateway` DO + +One DO per bot: + +- **WebSocket hibernation API** for the `/bot-gateway/ws` sessions. Heartbeats are answered via + `setWebSocketAutoResponse` / alarms. +- **Leases are implicit**: a DO is single-instance per id, so the Redis lease + `bot-gateway:lease:` disappears. +- **Durable Streams** (file-backed Node server) → an append-only event log in DO SQLite with + monotonic offsets. Replay from an offset on reconnect and ack/trim on `batch_ack`. Keep the + protocol shape so `libs/bot-sdk` changes stay minimal. +- The backend's `bot-gateway-service` writes via a DO RPC stub instead of HTTP to Durable Streams. + bot-commands SSE also lives on the DO. + +**As built** (`apps/bot-gateway/src/worker.ts`, `src/gateway/*`): bots send their token in the +first frame, not on the upgrade, so the gateway Worker terminates the bot's socket, sends HELLO, +answers HEARTBEATs, authenticates IDENTIFY/RESUME through the `HAZEL_DB` Hyperdrive, then relays +the session to `BotGateway.getByName(botId)` over a hibernatable DO socket. The Worker's +lease-TTL watchdog closes silent clients; the DO rejects a second session unless it RESUMEs the +live session's id (the old lease semantics). The backend picks a `BotGatewayTransport`: Durable +Streams HTTP on Bun, `layerDurableObject(namespace)` on Workers. The Bun gateway and +`docker/durable-streams` stay until the Bun backend is retired (Phase 7). + +## Phase 7: Bots, observability, cleanup + +- **`bots/hazel-bot`, `bots/linear-bot`**: **out of scope, separate follow-up.** They're rewritten + as Workers there, with a runtime-agnostic `libs/bot-sdk` (no `Bun.serve` / `BunRuntime`). Until + then they keep running where they are and connect to the new `BotGateway` over the same WS + protocol, so Phase 6 must stay protocol-compatible. +- **Observability**: Workers logs/traces destinations → Maple (already used by actors) + + `WorkerTelemetry`. The cluster exports OTLP directly. Delete `infra/otel-collector`. +- **Delete**: `railpack.config.mjs`, `Caddyfile*`, `docker/durable-streams`, and the Redis and MinIO + services in `docker-compose.yaml`. Local dev keeps only Postgres + Electric in docker. + +## Phase 8: CI/CD (copy maple's workflows) + +- `deploy-prd.yml`: `workflow_run` after CI on `main` → `bun run alchemy:deploy:prd` → `/health` + revision check. +- `deploy-pr-preview.yml`: `preview` label → `alchemy deploy --stage pr-`. Write URLs to + `$GITHUB_OUTPUT` and post a PR comment; destroy on close. PR stages get no Electric creds and their + own DB branch. +- `cleanup-preview-orphans.yml`. +- Secrets: GitHub Environments or Infisical OIDC (as maple). Set `CLOUDFLARE_API_TOKEN`, + `CLOUDFLARE_ACCOUNT_ID` and DB credentials. +- `typecheck` also runs `tsc -p tsconfig.alchemy.json`. + +## Cutover order and rollback + +1. Phase 0 (adopt existing CF resources): no user impact. +2. Postgres → PlanetScale. Railway services and Electric Cloud are repointed first; this is the + only step that needs a maintenance window. +3. Electric container + electric-proxy Worker: DNS switch; rollback = point DNS back at Railway + Electric Cloud. +4. Cluster: repoint to PlanetScale and add Tunnel/auth before the backend moves. +5. backend `api`: same hostname. Keep Railway warm for a week. +6. BotGateway DO: bot-sdk reconnect handles the switch. +7. Decommission on Railway: backend, bot-gateway, Redis, Durable Streams, collector. Also decommission Electric Cloud. **The cluster stays.** + +## Known risks + +- RivetKit DO class under alchemy (spike in Phase 0). +- Electric on Containers: single instance = brief sync outage on every deploy/restart, and the replication slot must be handled cleanly (spike in Phase 1). +- Cross-provider hop: every `api` → cluster call goes Cloudflare → Railway. That's fine for fire-and-forget workflow `execute`; watch any synchronous `poll`/result calls. +- Discord gateway in a DO: duration billing while connected; resume logic must be solid. +- Worker CPU limits on heavy webhook handlers: push work to Queues/Workflows. +- Hyperdrive + postgres.js prepared statements: verify, or switch to `pg` as maple did. +- alchemy is beta: pin the exact version, and keep maple's patch practice if needed. diff --git a/package.json b/package.json index 17c2f46c9..27045a4a9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "@effect/atom-react": "4.0.1", "@effect/ai-openrouter": "4.0.1", "@effect/vitest": "4.0.1" + }, + "alchemy": { + "@cloudflare/workers-types": "4.20260603.1", + "alchemy": "2.0.0-beta.80" } } }, @@ -36,12 +40,22 @@ "test": "vitest", "test:once": "vitest run", "test:debug": "vitest --inspect-brk --no-file-parallelism", - "test:coverage": "vitest run --coverage --coverage.reporter=text" + "test:coverage": "vitest run --coverage --coverage.reporter=text", + "alchemy:deploy:prd": "alchemy deploy --yes --adopt --stage ${HAZEL_STAGE:-prd}", + "alchemy:deploy:pr": "alchemy deploy --yes --adopt --stage pr-${PR_NUMBER}", + "alchemy:destroy:pr": "alchemy destroy --yes --stage pr-${PR_NUMBER}", + "alchemy:dev": "ALCHEMY_LOCAL_STATE=1 alchemy dev --stage dev_${USER} --env-file .env.local", + "alchemy:typecheck": "tsc -p tsconfig.alchemy.json" }, "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", "@effect/vitest": "catalog:effect", + "@hazel/infra": "workspace:*", "@rolldown/plugin-babel": "^0.2.1", + "@types/node": "^24", "@vitest/coverage-v8": "^4.1.0", + "alchemy": "catalog:alchemy", + "effect": "catalog:effect", "oxfmt": "^0.40.0", "oxlint": "^1.55.0", "rollup-plugin-visualizer": "7.0.1", diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index df3156721..34a949ab3 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -25,6 +25,7 @@ export { MessageDeletedPayloadSchema, MessageOutboxRepo, MessageUpdatedPayloadSchema, + OutboxWrites, ReactionCreatedPayloadSchema, ReactionDeletedPayloadSchema, type MessageCreatedPayload, diff --git a/packages/backend-core/src/repositories/index.ts b/packages/backend-core/src/repositories/index.ts index 609033155..bf23d7f5f 100644 --- a/packages/backend-core/src/repositories/index.ts +++ b/packages/backend-core/src/repositories/index.ts @@ -24,6 +24,7 @@ export { MessageDeletedPayloadSchema, MessageOutboxRepo, MessageUpdatedPayloadSchema, + OutboxWrites, ReactionCreatedPayloadSchema, ReactionDeletedPayloadSchema, type MessageCreatedPayload, diff --git a/packages/backend-core/src/repositories/message-outbox-repo.ts b/packages/backend-core/src/repositories/message-outbox-repo.ts index 6f91b1cb5..bb90f71e4 100644 --- a/packages/backend-core/src/repositories/message-outbox-repo.ts +++ b/packages/backend-core/src/repositories/message-outbox-repo.ts @@ -91,41 +91,59 @@ const InsertMessageOutboxEventSchema = Schema.Struct({ const InsertMessageOutboxEventArraySchema = Schema.Array(InsertMessageOutboxEventSchema) +/** + * Observes outbox inserts for the current request. A long-running dispatcher polls and needs + * nothing (the default is a no-op); the Cloudflare Worker installs a per-request recorder and + * wakes its dispatcher Durable Object once the response is sent and the write has committed. + */ +export class OutboxWrites extends Context.Reference<{ readonly mark: () => void }>("OutboxWrites", { + defaultValue: () => ({ mark: () => {} }), +}) {} + +const markOutboxWrite = Effect.gen(function* () { + const writes = yield* OutboxWrites + writes.mark() +}) + export class MessageOutboxRepo extends Context.Service()("MessageOutboxRepo", { make: Effect.gen(function* () { const db = yield* Database.Database const insert = (data: InsertMessageOutboxEvent, tx?: TxFn) => - db.makeQueryWithSchema(InsertMessageOutboxEventSchema, (execute, input) => - execute((client) => - client - .insert(schema.messageOutboxEventsTable) - .values({ - eventType: input.eventType, - aggregateId: input.aggregateId, - channelId: input.channelId, - payload: input.payload as Record, - }) - .returning(), - ), - )(data, tx) + db + .makeQueryWithSchema(InsertMessageOutboxEventSchema, (execute, input) => + execute((client) => + client + .insert(schema.messageOutboxEventsTable) + .values({ + eventType: input.eventType, + aggregateId: input.aggregateId, + channelId: input.channelId, + payload: input.payload as Record, + }) + .returning(), + ), + )(data, tx) + .pipe(Effect.tap(() => markOutboxWrite)) const insertMany = (data: ReadonlyArray, tx?: TxFn) => - db.makeQueryWithSchema(InsertMessageOutboxEventArraySchema, (execute, input) => - execute((client) => - client - .insert(schema.messageOutboxEventsTable) - .values( - input.map((event) => ({ - eventType: event.eventType, - aggregateId: event.aggregateId, - channelId: event.channelId, - payload: event.payload as Record, - })), - ) - .returning(), - ), - )(data, tx) + db + .makeQueryWithSchema(InsertMessageOutboxEventArraySchema, (execute, input) => + execute((client) => + client + .insert(schema.messageOutboxEventsTable) + .values( + input.map((event) => ({ + eventType: event.eventType, + aggregateId: event.aggregateId, + channelId: event.channelId, + payload: event.payload as Record, + })), + ) + .returning(), + ), + )(data, tx) + .pipe(Effect.tap(() => markOutboxWrite)) const claimNextBatch = (params: ClaimNextBatchParams, tx?: TxFn) => db.makeQuery((execute, data: ClaimNextBatchParams) => { diff --git a/packages/db/src/schema/organizations.ts b/packages/db/src/schema/organizations.ts index 0a0265db3..3c219b6b1 100644 --- a/packages/db/src/schema/organizations.ts +++ b/packages/db/src/schema/organizations.ts @@ -39,9 +39,7 @@ export const organizationsTable = pgTable( // claiming the same Clerk org. Partial so soft-deleted rows don't block re-creation. uniqueIndex("organizations_clerk_org_id_unique") .using("btree", sql`((${table.settings}->>'clerkOrganizationId'))`) - .where( - sql`${table.deletedAt} IS NULL AND ${table.settings}->>'clerkOrganizationId' IS NOT NULL`, - ), + .where(sql`${table.deletedAt} IS NULL AND ${table.settings}->>'clerkOrganizationId' IS NOT NULL`), ], ) diff --git a/packages/db/src/services/database.ts b/packages/db/src/services/database.ts index 8c0eee495..5ddc44a69 100644 --- a/packages/db/src/services/database.ts +++ b/packages/db/src/services/database.ts @@ -90,7 +90,34 @@ export type Config = { ssl: boolean } -const makeService = (config: Config) => +/** + * A drizzle client scoped to the current request. On Cloudflare Workers a TCP socket belongs to + * the request that opened it, so an isolate-wide pool cannot be shared across requests; the + * Worker provides one of these per request (see {@link makeRequestConnection}) and + * {@link layerRequestScoped} reads it on every query. + */ +export class DatabaseConnection extends Context.Service()( + "DatabaseConnection", +) {} + +/** + * A lazily-connecting client for one request. postgres.js opens no socket until the first + * query, so requests that never touch the database cost nothing. Through Hyperdrive the pool + * lives at the edge, so `max` stays small and type fetching is skipped. + */ +export const makeRequestConnection = ( + url: string, +): { readonly db: Client; readonly end: () => Promise } => { + const sql = postgres(url, { + max: 5, + fetch_types: false, + idle_timeout: 5, + connect_timeout: 10, + }) + return { db: drizzle(sql, { schema }), end: () => sql.end({ timeout: 5 }) } +} + +const makePooledClient = (config: Config) => Effect.gen(function* () { const sql = yield* Effect.acquireRelease( Effect.sync(() => @@ -119,24 +146,39 @@ const makeService = (config: Config) => ) const db = drizzle(sql, { schema }) + return Effect.succeed(db) + }) +/** The request's client; a defect when the Worker forgot to provide one. */ +const requestClient: Effect.Effect = Effect.serviceOption(DatabaseConnection).pipe( + Effect.flatMap((connection) => + Option.isSome(connection) + ? Effect.succeed(connection.value.db) + : Effect.die(new Error("DatabaseConnection is not provided for this request")), + ), +) + +const makeService = (getDb: Effect.Effect) => + Effect.gen(function* () { const execute = Effect.fn((fn: (client: Client) => Promise) => - Effect.tryPromise({ - try: () => fn(db), - catch: (cause) => { - const error = matchPgError(cause) - if (error !== null) { - return error - } - throw cause - }, - }), + Effect.flatMap(getDb, (db) => + Effect.tryPromise({ + try: () => fn(db), + catch: (cause) => { + const error = matchPgError(cause) + if (error !== null) { + return error + } + throw cause + }, + }), + ), ) const transaction = Effect.fn("Database.transaction")((effect: Effect.Effect) => - Effect.context().pipe( - Effect.map((services) => Effect.runPromiseExitWith(services)), - Effect.flatMap((runPromiseExit) => + Effect.all([getDb, Effect.context()]).pipe( + Effect.map(([db, services]) => [db, Effect.runPromiseExitWith(services)] as const), + Effect.flatMap(([db, runPromiseExit]) => Effect.callback((resume) => { db.transaction(async (tx: TransactionClient) => { const txWrapper: TxFn = (fn: (client: TransactionClient) => Promise) => @@ -258,4 +300,9 @@ type Shape = Effect.Success> export class Database extends Context.Service()("Database") {} -export const layer = (config: Config) => Layer.effect(Database, makeService(config)) +/** A long-lived pool, for long-running processes (Bun servers, the cluster). */ +export const layer = (config: Config) => + Layer.effect(Database, Effect.flatMap(makePooledClient(config), makeService)) + +/** Reads the per-request {@link DatabaseConnection}; for Cloudflare Workers and Durable Objects. */ +export const layerRequestScoped = Layer.effect(Database, makeService(requestClient)) diff --git a/packages/domain/src/bot-gateway.ts b/packages/domain/src/bot-gateway.ts index acff67935..110eda08c 100644 --- a/packages/domain/src/bot-gateway.ts +++ b/packages/domain/src/bot-gateway.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect" +import { type Effect, Schema } from "effect" import { Channel, ChannelMember, Message } from "./models" import { BotId, ChannelId, OrganizationId, UserId } from "@hazel/schema" @@ -181,3 +181,30 @@ export const BotGatewayServerFrame = Schema.Union([ ]) export type BotGatewayServerFrame = Schema.Schema.Type + +/** + * The `BotGateway` Durable Object's RPC contract (one object per bot, addressed by `BotId`). + * Shared by the gateway Worker that hosts the object and by the backend that publishes into it, + * so neither side imports the other's runtime. + */ +export class BotGatewayEventRejectedError extends Schema.TaggedError()( + "BotGatewayEventRejectedError", + { + message: Schema.String, + }, +) {} + +/** Where an appended event landed in the bot's log, in the protocol's offset format. */ +export interface BotGatewayPublishResult { + readonly offset: string +} + +export interface BotGatewayRpc { + /** + * Append one event (a `BotGatewayEnvelope` as JSON text, the exact wire form bots decode) to + * the bot's log and deliver it to the connected session, if any. + */ + readonly publish: ( + eventJson: string, + ) => Effect.Effect +} diff --git a/packages/domain/src/cluster/api.ts b/packages/domain/src/cluster/api.ts index bde8a21f2..d0fa67208 100644 --- a/packages/domain/src/cluster/api.ts +++ b/packages/domain/src/cluster/api.ts @@ -21,6 +21,13 @@ export const workflows = [ ] as const // HTTP API definition for the cluster service +/** + * Header carrying the cluster API's shared secret (`CLUSTER_API_SECRET`). The cluster stays on + * Railway while the backend runs on Cloudflare, so its workflow API is reachable from the public + * internet and rejects calls without it whenever the secret is configured. + */ +export const CLUSTER_API_SECRET_HEADER = "x-hazel-cluster-secret" + export class WorkflowApi extends HttpApi.make("api") .add(WorkflowProxy.toHttpApiGroup("workflows", workflows)) .add(HttpApiGroup.make("health").add(HttpApiEndpoint.get("ok", "/health", { success: Schema.String }))) {} diff --git a/packages/effect-bun/src/Telemetry.ts b/packages/effect-bun/src/Telemetry.ts index 1192f4d9b..430e53199 100644 --- a/packages/effect-bun/src/Telemetry.ts +++ b/packages/effect-bun/src/Telemetry.ts @@ -1,5 +1,5 @@ import { BunSocket } from "@effect/platform-bun" -import { Config, Effect, Layer } from "effect" +import { Config, Effect, Layer, Option, Redacted } from "effect" import { DevTools } from "effect/devtools" import { FetchHttpClient } from "effect/http" import { Otlp, OtlpSerialization } from "effect/observability" @@ -10,7 +10,9 @@ import { Otlp, OtlpSerialization } from "effect/observability" * Environment variables: * - OTEL_ENVIRONMENT (default: "local"): Environment (local/staging/production) * - RAILWAY_GIT_COMMIT_SHA / COMMIT_SHA (default: "unknown"): Git commit SHA for service version - * - OTEL_BASE_URL: OTLP collector endpoint (e.g. "http://otel-collector.railway.internal:4318") + * - OTEL_BASE_URL: OTLP endpoint (e.g. "https://ingest.maple.dev") + * - MAPLE_INGEST_KEY (optional): sent as `Authorization: Bearer …`, for exporting straight to + * Maple without a collector in between * * Behavior: * - local environment: Uses Effect DevTools WebSocket (ws://localhost:34437) @@ -50,9 +52,14 @@ export const createTracingLayer = (otelServiceName: string) => } const otelBaseUrl = yield* Config.String("OTEL_BASE_URL") + const ingestKey = yield* Config.option(Config.Redacted("MAPLE_INGEST_KEY")) return Otlp.layer({ baseUrl: otelBaseUrl, + headers: Option.match(ingestKey, { + onNone: () => undefined, + onSome: (key) => ({ authorization: `Bearer ${Redacted.value(key)}` }), + }), resource: { serviceName: otelServiceName, serviceVersion: commitSha, diff --git a/packages/effect-cloudflare/package.json b/packages/effect-cloudflare/package.json new file mode 100644 index 000000000..61344080e --- /dev/null +++ b/packages/effect-cloudflare/package.json @@ -0,0 +1,21 @@ +{ + "name": "@hazel/effect-cloudflare", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./KvPersistence": "./src/KvPersistence.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "effect": "catalog:effect" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", + "typescript": "^5.9.3" + } +} diff --git a/packages/effect-cloudflare/src/KvPersistence.ts b/packages/effect-cloudflare/src/KvPersistence.ts new file mode 100644 index 000000000..d5e6f10d8 --- /dev/null +++ b/packages/effect-cloudflare/src/KvPersistence.ts @@ -0,0 +1,86 @@ +import type { KVNamespace } from "@cloudflare/workers-types" +import { Duration, Effect, Layer } from "effect" +import { identity } from "effect/Function" +import { Persistence } from "effect/persistence" + +/** Workers KV rejects `expirationTtl` below 60 seconds. */ +const KV_MIN_TTL_SECONDS = 60 + +const persistenceError = (method: string, cause: unknown) => + new Persistence.PersistenceError({ message: `KV persistence error in ${method}`, cause }) + +const kvTtl = (ttl: Duration.Duration | undefined): { expirationTtl: number } | undefined => + ttl === undefined + ? undefined + : { expirationTtl: Math.max(KV_MIN_TTL_SECONDS, Math.ceil(Duration.toSeconds(ttl))) } + +/** + * `BackingPersistence` over a Workers KV namespace. KV is eventually consistent (writes take up + * to ~60s to reach other locations), which suits caches whose entries are TTL-bounded anyway. + * Replaces the Redis backing on Workers. + */ +export const makeKvBackingPersistence = (kv: KVNamespace) => + Persistence.BackingPersistence.of({ + make: (prefix) => + Effect.sync(() => { + const prefixed = (key: string) => `${prefix}:${key}` + + const get = (key: string) => + Effect.tryPromise({ + try: () => kv.get(prefixed(key), "json"), + catch: (cause) => persistenceError("get", cause), + }).pipe(Effect.map((value) => value ?? undefined)) + + const set = (key: string, value: object, ttl: Duration.Duration | undefined) => + Effect.tryPromise({ + try: () => kv.put(prefixed(key), JSON.stringify(value), kvTtl(ttl)), + catch: (cause) => persistenceError("set", cause), + }) + + return identity({ + get, + getMany: (keys) => + Effect.forEach(keys, get, { concurrency: "unbounded" }) as Effect.Effect< + any, + Persistence.PersistenceError + >, + set, + setMany: (entries) => + Effect.forEach(entries, ([key, value, ttl]) => set(key, value, ttl), { + concurrency: "unbounded", + discard: true, + }), + remove: (key) => + Effect.tryPromise({ + try: () => kv.delete(prefixed(key)), + catch: (cause) => persistenceError("remove", cause), + }), + clear: Effect.gen(function* () { + let cursor: string | undefined + do { + const page = yield* Effect.tryPromise({ + try: () => kv.list({ prefix: `${prefix}:`, cursor }), + catch: (cause) => persistenceError("clear", cause), + }) + yield* Effect.forEach( + page.keys, + (entry) => + Effect.tryPromise({ + try: () => kv.delete(entry.name), + catch: (cause) => persistenceError("clear", cause), + }), + { concurrency: 16, discard: true }, + ) + cursor = page.list_complete ? undefined : page.cursor + } while (cursor !== undefined) + }), + }) + }), + }) + +export const layerKvBackingPersistence = (kv: KVNamespace): Layer.Layer => + Layer.succeed(Persistence.BackingPersistence, makeKvBackingPersistence(kv)) + +/** `Persistence` (result persistence) backed by Workers KV. */ +export const layerKvResultPersistence = (kv: KVNamespace): Layer.Layer => + Persistence.layer.pipe(Layer.provide(layerKvBackingPersistence(kv))) diff --git a/packages/effect-cloudflare/src/index.ts b/packages/effect-cloudflare/src/index.ts new file mode 100644 index 000000000..6fe6fd1e8 --- /dev/null +++ b/packages/effect-cloudflare/src/index.ts @@ -0,0 +1 @@ +export * from "./KvPersistence.ts" diff --git a/packages/effect-cloudflare/tsconfig.json b/packages/effect-cloudflare/tsconfig.json new file mode 100644 index 000000000..6f88f9f96 --- /dev/null +++ b/packages/effect-cloudflare/tsconfig.json @@ -0,0 +1,15 @@ +{ + "include": ["src/**/*.ts"], + "compilerOptions": { + "target": "esnext", + "module": "preserve", + "lib": ["ES2023"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "types": ["@cloudflare/workers-types"] + } +} diff --git a/packages/infra/package.json b/packages/infra/package.json new file mode 100644 index 000000000..7e850e5be --- /dev/null +++ b/packages/infra/package.json @@ -0,0 +1,29 @@ +{ + "name": "@hazel/infra", + "version": "0.0.0", + "private": true, + "type": "module", + "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./cloudflare": "./src/cloudflare/index.ts", + "./env": "./src/env.ts", + "./worker-http": "./src/cloudflare/worker-http.ts", + "./worker-runtime": "./src/cloudflare/worker-runtime.ts", + "./cached-recoverable": "./src/cloudflare/cached-recoverable.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "alchemy": "catalog:alchemy", + "effect": "catalog:effect" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", + "@types/node": "^24.0.0", + "typescript": "^5.9.3", + "vitest": "^4.1.0" + } +} diff --git a/packages/infra/src/cloudflare/cached-recoverable.test.ts b/packages/infra/src/cloudflare/cached-recoverable.test.ts new file mode 100644 index 000000000..eec9662f9 --- /dev/null +++ b/packages/infra/src/cloudflare/cached-recoverable.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest" +import { Deferred, Effect, Exit, Fiber } from "effect" +import { cachedRecoverable } from "./cached-recoverable.ts" + +describe("cachedRecoverable", () => { + it("builds once and shares the success", async () => { + let builds = 0 + const program = Effect.gen(function* () { + const get = yield* cachedRecoverable(Effect.sync(() => ++builds)) + return [yield* get, yield* get] + }) + expect(await Effect.runPromise(program)).toEqual([1, 1]) + expect(builds).toBe(1) + }) + + it("forgets failures so the next call rebuilds", async () => { + let builds = 0 + const program = Effect.gen(function* () { + const get = yield* cachedRecoverable( + Effect.suspend(() => (++builds === 1 ? Effect.fail("boom") : Effect.succeed(builds))), + ) + const first = yield* Effect.exit(get) + return [first, yield* get] as const + }) + const [first, second] = await Effect.runPromise(program) + expect(Exit.isFailure(first)).toBe(true) + expect(second).toBe(2) + }) + + it("a waiter rebuilds when the in-flight build is interrupted", async () => { + let builds = 0 + const program = Effect.gen(function* () { + const gate = yield* Deferred.make() + const get = yield* cachedRecoverable( + Effect.suspend(() => { + builds++ + return builds === 1 + ? Effect.andThen(Deferred.await(gate), Effect.succeed(1)) + : Effect.succeed(builds) + }), + ) + const builder = yield* Effect.forkChild(get) + yield* Effect.yieldNow + const waiter = yield* Effect.forkChild(get) + yield* Effect.yieldNow + yield* Fiber.interrupt(builder) + return yield* Fiber.join(waiter) + }) + expect(await Effect.runPromise(program)).toBe(2) + }) +}) diff --git a/packages/infra/src/cloudflare/cached-recoverable.ts b/packages/infra/src/cloudflare/cached-recoverable.ts new file mode 100644 index 000000000..033ab6753 --- /dev/null +++ b/packages/infra/src/cloudflare/cached-recoverable.ts @@ -0,0 +1,38 @@ +import { Effect, Exit } from "effect" + +/** + * Single-flight `Effect.cached` that forgets failures, so a transient build failure is retried. + * Waiters await a Promise, not a `Deferred`: workerd resumes a promise in the awaiting request's + * I/O context, while a `Deferred` would resume it inside the first request's. + * + * An interrupted build (the first request's client went away) is not a result: waiters retry the + * build themselves instead of failing with someone else's interruption. + */ +export const cachedRecoverable = ( + self: Effect.Effect, +): Effect.Effect> => + Effect.sync(() => { + let success: Exit.Exit | undefined + let inFlight: Promise> | undefined + return Effect.gen(function* () { + while (inFlight !== undefined) { + const run = inFlight + const exit = yield* Effect.promise(() => run) + if (!(Exit.isFailure(exit) && Exit.hasInterrupts(exit))) return yield* exit + } + if (success !== undefined) return yield* success + let settle!: (exit: Exit.Exit) => void + inFlight = new Promise((resolve) => { + settle = resolve + }) + return yield* self.pipe( + Effect.onExit((exit) => + Effect.sync(() => { + if (Exit.isSuccess(exit)) success = exit + inFlight = undefined + settle(exit) + }), + ), + ) + }) + }) diff --git a/packages/infra/src/cloudflare/hazel-db.ts b/packages/infra/src/cloudflare/hazel-db.ts new file mode 100644 index 000000000..c79b0030a --- /dev/null +++ b/packages/infra/src/cloudflare/hazel-db.ts @@ -0,0 +1,121 @@ +/** + * Hazel's Postgres: an alchemy-managed PlanetScale database in prd, with one role per consumer, + * and the `HAZEL_DB` Hyperdrive config every Worker binds. PR previews and dev stages point the + * Hyperdrive at `HAZEL_PG_URL` (a shared preview database, or docker Postgres under `alchemy dev`). + * + * Schema changes stay on `drizzle-kit push` (packages/db has no migration files), so the stack + * declares no migrations. + */ +import * as Cloudflare from "alchemy/Cloudflare" +import * as Planetscale from "alchemy/Planetscale" +import * as RemovalPolicy from "alchemy/RemovalPolicy" +import * as Effect from "effect/Effect" +import type * as Option from "effect/Option" +import * as Redacted from "effect/Redacted" +import * as Schema from "effect/Schema" +import { plainWithDefault, requiredPlain } from "../env.ts" +import { type HazelStage, resolveWorkerName } from "./stage.ts" + +/** The binding name every Worker reads the connection from. */ +export const HAZEL_DB_BINDING = "HAZEL_DB" + +/** The PlanetScale database, created by the prd stack and retained on destroy. */ +export const PLANETSCALE_DATABASE = "hazel" + +export interface HazelDbResources { + readonly hyperdrive: Cloudflare.Hyperdrive.Connection + /** prd only: the replication role Electric connects with (directly, not via Hyperdrive). */ + readonly electricRole: Planetscale.PostgresRole | undefined + /** prd only: the role the Railway-hosted cluster connects with. */ + readonly clusterRole: Planetscale.PostgresRole | undefined +} + +/** Docker Postgres from docker-compose.yaml, used by `alchemy dev`. */ +const DEV_ORIGIN = { + scheme: "postgres" as const, + host: "localhost", + port: 5432, + database: "app", + user: "user", + password: Redacted.make("password"), + // Docker Postgres has no TLS; alchemy's default `prefer` stalls until timeout. + sslmode: "disable" as const, +} + +const originFromUrl = (raw: string) => + Effect.gen(function* () { + const url = yield* Effect.try(() => new URL(raw)).pipe(Effect.orDie) + return { + scheme: "postgres" as const, + host: url.hostname, + port: Number(url.port || "5432"), + database: url.pathname.replace(/^\//, "") || "postgres", + user: decodeURIComponent(url.username), + password: Redacted.make(decodeURIComponent(url.password)), + } + }) + +/** + * Declare the database resources for a stage. Yield from the root stack only: the + * `HAZEL_PG_URL` read must happen outside any Worker init, where alchemy would bind it as a secret. + */ +export const declareHazelDb = (stage: HazelStage) => + Effect.gen(function* () { + const name = resolveWorkerName("db", stage) + + if (stage.kind !== "prd") { + const hyperdrive = yield* Cloudflare.Hyperdrive.Connection(HAZEL_DB_BINDING, { + name, + origin: yield* originFromUrl(yield* requiredPlain("HAZEL_PG_URL")), + caching: { disabled: true }, + dev: DEV_ORIGIN, + }) + const resources: HazelDbResources = { + hyperdrive, + electricRole: undefined, + clusterRole: undefined, + } + return resources + } + + const { HAZEL_PG_CLUSTER_SIZE } = yield* plainWithDefault("HAZEL_PG_CLUSTER_SIZE", "PS_10") + const database = yield* Planetscale.PostgresDatabase("hazel-db", { + name: PLANETSCALE_DATABASE, + clusterSize: HAZEL_PG_CLUSTER_SIZE ?? "PS_10", + }).pipe(RemovalPolicy.retain()) + + // Distinct ids on purpose: alchemy keys state by id alone, across resource types. + const apiRole = yield* Planetscale.PostgresRole("db-api-role", { + database, + inheritedRoles: ["postgres"], + }) + const electricRole = yield* Planetscale.PostgresRole("db-electric-role", { + database, + inheritedRoles: ["postgres"], + withReplication: true, + }) + const clusterRole = yield* Planetscale.PostgresRole("db-cluster-role", { + database, + inheritedRoles: ["postgres"], + }) + + const hyperdrive = yield* Cloudflare.Hyperdrive.Connection(HAZEL_DB_BINDING, { + name, + origin: apiRole.origin, + // Read-after-write everywhere (messages, outbox claims). + caching: { disabled: true }, + dev: DEV_ORIGIN, + }) + const resources: HazelDbResources = { hyperdrive, electricRole, clusterRole } + return resources + }) + +/** What a Worker reads off the `HAZEL_DB` binding: the runtime `Hyperdrive` object's connection facts. */ +const HazelDbBinding = Schema.Struct({ + connectionString: Schema.String.check(Schema.isNonEmpty()), +}) +export type HazelDbBinding = typeof HazelDbBinding.Type + +/** The `HAZEL_DB` binding off a Worker env, or `None` when absent or not a Hyperdrive object. */ +export const readHazelDbBinding = (env: Record): Option.Option => + Schema.decodeUnknownOption(HazelDbBinding)(env[HAZEL_DB_BINDING]) diff --git a/packages/infra/src/cloudflare/index.ts b/packages/infra/src/cloudflare/index.ts new file mode 100644 index 000000000..2a6274488 --- /dev/null +++ b/packages/infra/src/cloudflare/index.ts @@ -0,0 +1,4 @@ +export * from "./cached-recoverable.ts" +export * from "./hazel-db.ts" +export * from "./stack.ts" +export * from "./stage.ts" diff --git a/packages/infra/src/cloudflare/stack.ts b/packages/infra/src/cloudflare/stack.ts new file mode 100644 index 000000000..8752a57bc --- /dev/null +++ b/packages/infra/src/cloudflare/stack.ts @@ -0,0 +1,60 @@ +import type * as Cloudflare from "alchemy/Cloudflare" +import { Stage } from "alchemy/Stage" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import type { HazelDbResources } from "./hazel-db.ts" +import { type HazelDomains, type HazelStage, parseHazelStage, resolveWorkerName } from "./stage.ts" + +/** Inter-app public origins as plan-time strings (custom domains in prd/pr, env in dev). */ +export interface HazelUrls { + readonly web: string + readonly api: string + readonly electric: string + readonly rivet: string + readonly linkPreview: string +} + +export interface HazelStackContext { + readonly stage: HazelStage + readonly domains: HazelDomains + readonly urls: HazelUrls + /** True under `alchemy dev` (not merely a dev stage: a dev stage can still be deployed). */ + readonly isDevServer: boolean + /** The database resources every DB-backed Worker binds. */ + readonly db: HazelDbResources +} + +/** The deploy context Worker props read. Plan-time only: read behind `__ALCHEMY_RUNTIME__`. */ +export class HazelStack extends Context.Service()("@hazel/infra/HazelStack") {} + +/** + * The deployed api Worker, for sibling Workers that bind it. Not a `Worker.ref`: that reads + * stored state and cannot see a sibling created by the same deploy. + */ +export class ApiWorker extends Context.Service()("@hazel/infra/ApiWorker") {} + +/** + * Props for a module-scope resource with a stage-derived name. Reads alchemy's `Stage` (not + * `HazelStack`) so it can be yielded from a Worker init too; returns `{}` under `__ALCHEMY_RUNTIME__`. + */ +export const stageProps = ( + base: string, + make: (name: string, stage: HazelStage) => Props, +): Effect.Effect, never, Stage> => + Effect.gen(function* () { + if (globalThis.__ALCHEMY_RUNTIME__) return {} + const stage = parseHazelStage(yield* Stage) + return make(resolveWorkerName(base, stage), stage) + }) + +/** {@link stageProps} for the common case: a resource whose only stage-derived prop is `name`. */ +export const stageNamed = (base: string) => stageProps(base, (name) => ({ name })) + +/** Workers runtime compatibility date for every Hazel Worker. */ +export const WORKER_COMPATIBILITY_DATE = "2026-10-01" + +/** The props every Hazel Worker shares. `app` is also the name base. */ +export const hazelWorkerProps = (app: string, { stage }: HazelStackContext) => ({ + name: resolveWorkerName(app, stage), + compatibility: { date: WORKER_COMPATIBILITY_DATE, flags: ["nodejs_compat"] }, +}) diff --git a/packages/infra/src/cloudflare/stage.ts b/packages/infra/src/cloudflare/stage.ts new file mode 100644 index 000000000..cc9139e9f --- /dev/null +++ b/packages/infra/src/cloudflare/stage.ts @@ -0,0 +1,134 @@ +import * as Effect from "effect/Effect" +import * as Result from "effect/Result" +import * as Schema from "effect/Schema" + +/** An alchemy stage string that names no deployable Hazel stage. */ +export class HazelStageError extends Schema.TaggedError()("@hazel/infra/HazelStageError", { + message: Schema.String, + rawStage: Schema.String, +}) {} + +export type HazelStage = { kind: "prd" } | { kind: "pr"; prNumber: number } | { kind: "dev"; name: string } + +const PR_STAGE_RE = /^pr-(\d+)$/ +// Underscores allowed so alchemy's default `dev_${USER}` stage parses as a dev stage. +const DEV_STAGE_RE = /^[a-z0-9][a-z0-9_-]*$/ + +/** Public hostnames per deployment. Dev stages have none (workers.dev / localhost). */ +export interface HazelDomains { + readonly web?: string + readonly landing?: string + readonly docs?: string + readonly api?: string + readonly electric?: string + readonly linkPreview?: string + readonly rivet?: string + readonly botGateway?: string +} + +export const ZONE = "hazel.sh" + +const PRD_DOMAINS: HazelDomains = { + web: "app.hazel.sh", + landing: "hazel.sh", + docs: "docs.hazel.sh", + api: "api.hazel.sh", + electric: "electric.hazel.sh", + linkPreview: "link-preview.hazel.sh", + rivet: "rivet.hazel.sh", + botGateway: "bot-gateway.hazel.sh", +} + +const hazelStageResult = (stage: string): Result.Result => { + const normalized = stage.trim().toLowerCase() + if (normalized === "prd") return Result.succeed({ kind: "prd" }) + + const prMatch = normalized.match(PR_STAGE_RE) + if (prMatch) { + const prNumber = Number(prMatch[1]) + if (Number.isSafeInteger(prNumber) && prNumber > 0) return Result.succeed({ kind: "pr", prNumber }) + } + + if (DEV_STAGE_RE.test(normalized)) { + // Cloudflare names allow only [a-z0-9-]. + return Result.succeed({ kind: "dev", name: normalized.replaceAll("_", "-") }) + } + + return Result.fail( + new HazelStageError({ + message: `Unsupported deployment stage "${stage}". Expected prd, pr-, or a dev stage name matching [a-z0-9][a-z0-9_-]*.`, + rawStage: stage, + }), + ) +} + +/** Parse an alchemy stage string, failing with {@link HazelStageError}. */ +export const parseHazelStageEffect = (raw: string): Effect.Effect => + Effect.fromResult(hazelStageResult(raw)) + +/** Synchronous {@link parseHazelStageEffect}: throws the `HazelStageError`. For non-Effect callers only. */ +export function parseHazelStage(raw: string): HazelStage { + return Result.getOrThrow(hazelStageResult(raw)) +} + +export function formatHazelStage(stage: HazelStage): string { + switch (stage.kind) { + case "prd": + return "prd" + case "pr": + return `pr-${stage.prNumber}` + case "dev": + return stage.name + } +} + +/** `OTEL_ENVIRONMENT` / `NODE_ENV`-style environment name for a stage. */ +export function resolveDeploymentEnvironment(stage: HazelStage): string { + switch (stage.kind) { + case "prd": + return "production" + case "pr": + return `pr-${stage.prNumber}` + case "dev": + return "development" + } +} + +export function resolveHazelDomains(stage: HazelStage): HazelDomains { + switch (stage.kind) { + case "prd": + return PRD_DOMAINS + case "pr": + // Custom domains, not workers.dev: inter-app URLs must be plan-time strings. + return { + web: `app-pr-${stage.prNumber}.${ZONE}`, + api: `api-pr-${stage.prNumber}.${ZONE}`, + electric: `electric-pr-${stage.prNumber}.${ZONE}`, + } + case "dev": + return {} + } +} + +/** + * Physical Worker/bucket/Hyperdrive name. prd keeps the pre-alchemy wrangler names (see + * {@link LEGACY_PRD_NAMES}) so `--adopt` takes over the existing Workers instead of replacing them. + */ +export function resolveWorkerName(base: string, stage: HazelStage): string { + switch (stage.kind) { + case "prd": + return LEGACY_PRD_NAMES[base] ?? `hazel-${base}` + case "pr": + return `hazel-${base}-pr-${stage.prNumber}` + case "dev": + return `hazel-${base}-dev-${stage.name}` + } +} + +/** Names the wrangler-deployed prd Workers already have. */ +const LEGACY_PRD_NAMES: Record = { + web: "hazel-app", + landing: "hazel-landing", + "link-preview": "link-preview-worker", + actors: "hazel-actors", +} diff --git a/packages/infra/src/cloudflare/worker-http.ts b/packages/infra/src/cloudflare/worker-http.ts new file mode 100644 index 000000000..7b78c6879 --- /dev/null +++ b/packages/infra/src/cloudflare/worker-http.ts @@ -0,0 +1,64 @@ +import * as Cloudflare from "alchemy/Cloudflare" +import type { HttpEffect } from "alchemy/Http" +import { Context, Effect, Exit, FileSystem, Layer, Logger, Path, Scope } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/http" +import * as Etag from "effect/http/Etag" +import * as HttpPlatform from "effect/http/HttpPlatform" + +const WorkerFileSystemLive = FileSystem.layerNoop({}) + +const WorkerHttpPlatformLive = Layer.effect( + HttpPlatform.HttpPlatform, + HttpPlatform.make({ + platform: "web", + compression: HttpPlatform.makeCompressionWeb({ + algorithms: ["gzip", "deflate"], + transform: (algorithm) => HttpPlatform.compressionTransformWeb(algorithm), + }), + fileResponse: (_path, status, statusText, headers) => + HttpServerResponse.text("File responses are unavailable in the worker runtime", { + status, + statusText, + headers, + }), + fileWebResponse: (_file, status, statusText, headers) => + HttpServerResponse.text("File responses are unavailable in the worker runtime", { + status, + statusText, + headers, + }), + }), +).pipe(Layer.provideMerge(WorkerFileSystemLive), Layer.provideMerge(Etag.layer)) + +export const WorkerPlatformLive = Layer.mergeAll(Path.layer, WorkerHttpPlatformLive) + +/** + * The init's context minus execution context, memo map and loggers: `HttpApiBuilder.group` + * captures its build context and it wins over the event's, so handlers would log to the console. + */ +export const isolateContext = (context: Context.Context): Context.Context => + Context.omit(Cloudflare.WorkerExecutionContext, Layer.CurrentMemoMap, Logger.CurrentLoggers)(context) + +/** + * Runs a build under the isolate's context, never the first event's fiber (a graph built inside + * request A would serve every later request with A's request and context). Scope closes only on failure. + */ +export const forIsolate = + (isolate: Context.Context) => + (build: Effect.Effect): Effect.Effect => + Effect.gen(function* () { + const scope = yield* Scope.make() + return yield* build.pipe( + Scope.provide(scope), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(scope, exit) : Effect.void)), + ) + }).pipe(Effect.updateContext((_: Context.Context) => isolate)) + +/** SAFETY: the bridge's `safeHttpEffect` renders any escaping cause, so the markers are discharged here. */ +export const bridgeHandler = ( + handler: Effect.Effect< + HttpServerResponse.HttpServerResponse, + E, + R | Scope.Scope | HttpServerRequest.HttpServerRequest + >, +): HttpEffect => handler as HttpEffect diff --git a/packages/infra/src/cloudflare/worker-runtime.ts b/packages/infra/src/cloudflare/worker-runtime.ts new file mode 100644 index 000000000..72d578d1f --- /dev/null +++ b/packages/infra/src/cloudflare/worker-runtime.ts @@ -0,0 +1,23 @@ +/** + * The Worker env as an Effect service. Same key as alchemy's `Cloudflare.WorkerEnvironment`, so + * it is the same service, typed `Record` so binding reads must narrow. + */ +import { reifyBoundConfigProvider } from "alchemy/Runtime" +import * as ConfigProvider from "effect/ConfigProvider" +import * as Context from "effect/Context" +import * as Layer from "effect/Layer" + +/** The Worker's `env`, under alchemy's key. */ +export class WorkerEnvironment extends Context.Service>()( + "Cloudflare.Workers.WorkerEnvironment", +) {} + +/** + * The env as `WorkerEnvironment` plus Effect's `ConfigProvider`, so `Config.String("FOO")` resolves against the bindings. + * Alchemy's reifier unwraps the Redacted markers its deploy-time `Config` auto-binding leaves on the env. + */ +export const workerEnvLayer = (env: Record): Layer.Layer => + Layer.mergeAll( + Layer.succeed(WorkerEnvironment, env), + ConfigProvider.layer(reifyBoundConfigProvider(ConfigProvider.fromUnknown(env), env)), + ) diff --git a/packages/infra/src/cloudflare/workers-cache.ts b/packages/infra/src/cloudflare/workers-cache.ts new file mode 100644 index 000000000..011898031 --- /dev/null +++ b/packages/infra/src/cloudflare/workers-cache.ts @@ -0,0 +1,17 @@ +import * as Context from "effect/Context" +import * as Layer from "effect/Layer" + +declare global { + // The DOM lib omits `caches.default`; this matches workers-types' declaration exactly so + // it merges cleanly under both configs. + interface CacheStorage { + readonly default: Cache + } +} + +/** `caches.default` as a service, or `null` outside a Workers runtime. */ +export class WorkersCache extends Context.Service()("@hazel/infra/WorkersCache") { + static readonly layer: Layer.Layer = Layer.sync(this, () => + typeof caches !== "undefined" ? caches.default : null, + ) +} diff --git a/packages/infra/src/config-helpers.ts b/packages/infra/src/config-helpers.ts new file mode 100644 index 000000000..7bfd1f870 --- /dev/null +++ b/packages/infra/src/config-helpers.ts @@ -0,0 +1,23 @@ +// `Config` helpers for runtime Worker env schemas. Imports only `effect`; keep it out of the +// package index, which pulls in deploy-side deps. +import { Config, Option, Redacted } from "effect" + +/** `Config.String(key)` with a fallback when the env var is unset. */ +export const stringWithDefault = (key: string, fallback: string) => + Config.String(key).pipe(Config.withDefault(fallback)) + +/** Optional string; treats a blank/whitespace-only value as absent (`None`). */ +export const optionalString = (key: string) => + Config.option(Config.String(key)).pipe( + Config.map((opt) => + Option.flatMap(opt, (s) => (s.trim().length > 0 ? Option.some(s) : Option.none())), + ), + ) + +/** Optional redacted secret; treats a blank/whitespace-only value as absent (`None`). */ +export const optionalRedacted = (key: string) => + Config.option(Config.String(key)).pipe( + Config.map((opt) => + Option.flatMap(opt, (s) => (s.trim().length > 0 ? Option.some(Redacted.make(s)) : Option.none())), + ), + ) diff --git a/packages/infra/src/env.ts b/packages/infra/src/env.ts new file mode 100644 index 000000000..f9f55a0b8 --- /dev/null +++ b/packages/infra/src/env.ts @@ -0,0 +1,95 @@ +import * as Config from "effect/Config" +import * as Option from "effect/Option" +import * as Redacted from "effect/Redacted" +import * as Schema from "effect/Schema" +import { optionalString } from "./config-helpers.ts" +import { type HazelStage, resolveDeploymentEnvironment } from "./cloudflare/stage.ts" + +/** + * Deploy-time Worker env, as effect `Config`s: never read `process.env`, which misses alchemy's + * `.env` / `--env-file` provider. Values are trimmed (blank = absent), absent optional keys are + * omitted (never `""`), and secrets are `Redacted` (alchemy uploads those as Worker secrets). + * + * Read these only from the stack or a Worker's `props`, never from a Worker init: at plan time + * alchemy auto-binds every `Config` read inside an init as a secret. + */ + +export type PlainEnv = Record +export type SecretEnv = Record> +/** A union-valued record, not `PlainEnv & SecretEnv` (that intersection is uninhabited). */ +export type WorkerEnv = Record> + +const trimmedOption = (key: string): Config.Config> => + optionalString(key).pipe( + Config.map((value: Option.Option) => Option.map(value, (raw) => raw.trim())), + ) + +/** Merge several partial-record configs into one. */ +export const merge = (...parts: ReadonlyArray>>): Config.Config => + Config.all(parts).pipe( + Config.map( + (records: ReadonlyArray>) => Object.assign({}, ...records) as WorkerEnv, + ), + ) + +/** Required plain value, trimmed; blank fails with a `ConfigError`. */ +export const requiredPlain = (key: string): Config.Config => + Config.schema(Schema.Trim.check(Schema.isNonEmpty()), key) + +/** Required secret, wrapped in `Redacted`. */ +export const requiredSecret = (key: string): Config.Config> => + requiredPlain(key).pipe(Config.map(Redacted.make)) + +export const requirePlainEntry = (key: string): Config.Config => + requiredPlain(key).pipe(Config.map((value) => ({ [key]: value }))) + +export const requireSecretEntry = (key: string): Config.Config => + requiredSecret(key).pipe(Config.map((value) => ({ [key]: value }))) + +/** Optional plain value, omitted when unset. `fallback` applies only if the key is absent. */ +export const optionalPlain = (key: string, fallback?: string): Config.Config => + trimmedOption(key).pipe( + Config.map((value) => { + const resolved = Option.getOrUndefined(value) ?? fallback?.trim() + return resolved ? { [key]: resolved } : {} + }), + ) + +/** Optional secret, omitted when unset. */ +export const optionalSecret = (key: string): Config.Config => + trimmedOption(key).pipe( + Config.map((value) => + Option.match(value, { onNone: () => ({}), onSome: (v) => ({ [key]: Redacted.make(v) }) }), + ), + ) + +/** The first present-and-non-blank of `keys`, else `fallback`. For build vars with a `VITE_` twin. */ +export const plainFrom = (keys: ReadonlyArray, fallback: string): Config.Config => + Config.all(keys.map(trimmedOption)).pipe( + Config.map((values: ReadonlyArray>) => + Option.getOrElse(Option.firstSomeOf(values), () => fallback), + ), + ) + +/** Optional value whose default also applies to a BLANK var (unlike `Config.withDefault`). */ +export const plainWithDefault = (key: string, fallback: string): Config.Config => + trimmedOption(key).pipe(Config.map((value) => ({ [key]: Option.getOrElse(value, () => fallback) }))) + +/** A value the stack chooses; the environment cannot override it. */ +export const derived = (key: string, value: string): Config.Config => + Config.succeed({ [key]: value }) + +/** OTLP export + environment stamping shared by every Effect Worker. */ +export const telemetryEnv = (stage: HazelStage): Config.Config => + merge( + derived("OTEL_ENVIRONMENT", resolveDeploymentEnvironment(stage)), + derived("NODE_ENV", stage.kind === "dev" ? "development" : "production"), + optionalPlain("OTEL_BASE_URL"), + optionalSecret("MAPLE_INGEST_KEY"), + merge(optionalPlain("COMMIT_SHA"), optionalPlain("GITHUB_SHA")).pipe( + Config.map((record): PlainEnv => { + const sha = record.COMMIT_SHA ?? record.GITHUB_SHA + return typeof sha === "string" && sha ? { COMMIT_SHA: sha } : {} + }), + ), + ) diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts new file mode 100644 index 000000000..b6e118293 --- /dev/null +++ b/packages/infra/src/index.ts @@ -0,0 +1 @@ +export * from "./cloudflare/index.ts" diff --git a/packages/infra/tsconfig.json b/packages/infra/tsconfig.json new file mode 100644 index 000000000..3afc8a639 --- /dev/null +++ b/packages/infra/tsconfig.json @@ -0,0 +1,16 @@ +{ + "include": ["src/**/*.ts"], + "compilerOptions": { + "target": "esnext", + "module": "preserve", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "types": ["node", "@cloudflare/workers-types"] + } +} diff --git a/tsconfig.alchemy.json b/tsconfig.alchemy.json new file mode 100644 index 000000000..ac82ca91c --- /dev/null +++ b/tsconfig.alchemy.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "target": "esnext", + "module": "preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + // workers-types is load-bearing: without the ambient globals, alchemy's InferEnv types + // silently resolve to `any` under skipLibCheck. + "types": ["node", "@cloudflare/workers-types"], + "paths": { + "@hazel/infra": ["./packages/infra/src/index.ts"], + "@hazel/infra/cloudflare": ["./packages/infra/src/cloudflare/index.ts"], + "@hazel/infra/env": ["./packages/infra/src/env.ts"], + "@hazel/infra/worker-http": ["./packages/infra/src/cloudflare/worker-http.ts"], + "@hazel/infra/worker-runtime": ["./packages/infra/src/cloudflare/worker-runtime.ts"], + "@hazel/infra/cached-recoverable": ["./packages/infra/src/cloudflare/cached-recoverable.ts"] + } + }, + "include": ["alchemy.run.ts", "apps/*/alchemy.run.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts index ad069f3a8..875332a28 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ "apps/link-preview-worker", "apps/web", "libs/*", - "!apps/bot-gateway", + "apps/bot-gateway", ], coverage: { reporter: ["text", "json-summary", "json"],