From aafac6633233298f0223786f03cf8f7120259583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Thu, 6 Aug 2026 19:22:19 +0200 Subject: [PATCH 01/10] ci: harden validation pipeline and wire dormant test suites - Run lint in CI: root lint script now runs the lint rules alone (vp check bundles the formatter, which the repo deliberately does not enforce) and verify:quick runs it, so Repository CI lints every PR. - Wire packages/clickhouse-db tests into turbo test (4 files were never run). - Quarantine apps/www component tests as test:components with the dual-React hoisted-install root cause documented in vite.config.ts; two files that crash even in a warm tree stay excluded. - Extract scripts/integration-suites.mjs and cross-check it from check-test-tiers.mjs, so a package that gains integration tests without being registered with the runner fails verify:quick instead of silently never running. - Add test:e2e:release to verify for parity with the Self-host Compose workflow. - Notify the monorepo on main pushes (notify-mono.yml) so its submodule bump automation reacts immediately; warns and passes while MONO_DISPATCH_TOKEN is unset. - Never cancel in-progress CI on main; drop the dead knip turbo task. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 ++++-- .github/workflows/notify-mono.yml | 31 +++++++++++++++++++++++++++++ .github/workflows/selfhost.yml | 2 +- apps/www/package.json | 3 ++- apps/www/vite.config.ts | 18 +++++++++++++++++ package.json | 6 +++--- packages/clickhouse-db/package.json | 3 ++- packages/clickhouse-db/vitest.mts | 11 ++++++++++ scripts/check-test-tiers.mjs | 15 ++++++++++++++ scripts/integration-suites.mjs | 17 ++++++++++++++++ scripts/run-local-integration.mjs | 13 +++--------- turbo.json | 3 --- 12 files changed, 107 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/notify-mono.yml create mode 100644 packages/clickhouse-db/vitest.mts create mode 100644 scripts/integration-suites.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6215afed0..84cbf5b80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,11 @@ on: permissions: contents: read +# Superseded PR pushes cancel their stale runs; main never cancels, so every +# main commit keeps a complete CI verdict. concurrency: group: repository-ci-${{ github.head_ref || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: # Keeps turbo within the runner's 4 vCPUs; the scripts stay flag-free so the @@ -73,6 +75,6 @@ jobs: # The same command developers run locally. The stack-backed tiers # (`test:integration`, `test:e2e`) run in the Self-host Compose workflow, # which owns the Compose lifecycle; together they cover `pnpm verify`. - - name: Verify (typecheck + unit tier) + - name: Verify (lint + typecheck + unit tier) if: github.event_name != 'pull_request' || github.event.pull_request.draft == false run: pnpm verify:quick diff --git a/.github/workflows/notify-mono.yml b/.github/workflows/notify-mono.yml new file mode 100644 index 000000000..faa1430f1 --- /dev/null +++ b/.github/workflows/notify-mono.yml @@ -0,0 +1,31 @@ +# Tells the monorepo that main moved, so its Bump voidhash workflow can open a +# submodule bump PR immediately (its daily cron is the fallback). MONO_DISPATCH_TOKEN +# must be a PAT that can send repository_dispatch to voidhashcom/voidhash-mono; +# when it is missing the step warns and succeeds, because the cron still covers +# detection. +name: Notify mono + +on: + push: + branches: [main] + +permissions: + contents: read + +jobs: + dispatch: + name: Dispatch bump event + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 5 + steps: + - name: Send repository_dispatch to voidhash-mono + env: + GH_TOKEN: ${{ secrets.MONO_DISPATCH_TOKEN }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::warning title=MONO_DISPATCH_TOKEN not set::Skipping the dispatch; voidhash-mono's daily bump cron remains the only drift detection." + exit 0 + fi + gh api repos/voidhashcom/voidhash-mono/dispatches \ + -f event_type=voidhash-main-push \ + -f 'client_payload[sha]='"${GITHUB_SHA}" diff --git a/.github/workflows/selfhost.yml b/.github/workflows/selfhost.yml index 077c8f6aa..3682a76e3 100644 --- a/.github/workflows/selfhost.yml +++ b/.github/workflows/selfhost.yml @@ -13,7 +13,7 @@ permissions: concurrency: group: selfhost-compose-${{ github.head_ref || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: COMPOSE_PROJECT_NAME: voidhash-selfhost-ci-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/apps/www/package.json b/apps/www/package.json index a763a05cc..33fe97871 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -10,7 +10,8 @@ "preview": "vp preview", "typecheck": "pnpm run generate:docs && tsc --noEmit", "generate:docs": "fumadocs-mdx src/features/source.config.ts .source", - "generate:openapi": "bun scripts/generate-openapi.ts" + "generate:openapi": "bun scripts/generate-openapi.ts", + "test:components": "vp test run" }, "dependencies": { "@apple/app-store-server-library": "^1.6.0", diff --git a/apps/www/vite.config.ts b/apps/www/vite.config.ts index 2af31cf46..10c15c8b0 100644 --- a/apps/www/vite.config.ts +++ b/apps/www/vite.config.ts @@ -97,8 +97,26 @@ export default defineConfig(() => ({ build: { minify: "esbuild", }, + // Run with `pnpm test:components` — deliberately NOT the `test` script, so + // `turbo test` (and therefore CI `verify:quick`) skips this suite for now. + // The hoisted install materializes MULTIPLE React instances (root 19.2.x + // plus dozens of nested copies under @radix-ui packages and sonner; the root + // `resolutions` react pin is not applied by pnpm's hoisted linker), and in a + // pristine `pnpm install --frozen-lockfile` checkout the dual instances make + // ~30 of these files fail — Radix components silently render nothing when + // their React differs from react-dom's. Aligning the install on a single + // React (overrides/catalog surgery, verified in a fresh clone) is the + // prerequisite; once done, rename `test:components` to `test` so the suite + // joins `turbo test` and CI. The two excluded files below crash on the dual + // instance even in a warm working tree. test: { setupFiles: ["./src/test-setup.ts"], + exclude: [ + "**/node_modules/**", + "**/dist/**", + "src/features/studio/paywalls/designer/canvas/helpers/selectable.test.tsx", + "src/features/studio/paywalls/designer/panel-runtime/host-renderer.test.tsx", + ], }, plugins: [ ...mdx(sourceConfig, { diff --git a/package.json b/package.json index 8c58e8029..8621f300e 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "dev": "turbo dev", "clean": "turbo clean && rm -rf node_modules", "format": "vp check --fix", - "lint": "vp check", + "lint": "vp lint", "bump": "bumpp", "openapi:generate": "node ./scripts/generate-openapi-clients.mjs", "openapi:generate:dev": "pnpm openapi:generate -- localhost:8787", @@ -54,8 +54,8 @@ "check:test-tiers": "node ./scripts/check-test-tiers.mjs", "stack:up": "SELFHOST_MODE=local-evaluation docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env --profile analytics --project-directory selfhost up -d --build", "stack:down": "SELFHOST_MODE=local-evaluation docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --env-file .env --profile analytics --project-directory selfhost down", - "verify": "pnpm verify:quick && pnpm test:integration && pnpm test:e2e", - "verify:quick": "pnpm check:publication && pnpm check:platform-seam && pnpm check:selfhost-runtime && pnpm check:test-tiers && pnpm typecheck && pnpm test", + "verify": "pnpm verify:quick && pnpm test:integration && pnpm test:e2e && pnpm test:e2e:release", + "verify:quick": "pnpm check:publication && pnpm check:platform-seam && pnpm check:selfhost-runtime && pnpm check:test-tiers && pnpm lint && pnpm typecheck && pnpm test", "test": "turbo test", "test:integration": "node ./scripts/run-local-integration.mjs", "test:e2e": "tsx selfhost/smoke.mts", diff --git a/packages/clickhouse-db/package.json b/packages/clickhouse-db/package.json index d00676b7d..ec00b74ec 100644 --- a/packages/clickhouse-db/package.json +++ b/packages/clickhouse-db/package.json @@ -14,7 +14,8 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "typecheck-go": "tsgo --noEmit" + "typecheck-go": "tsgo --noEmit", + "test": "vp test run -c vitest.mts" }, "dependencies": { "@clickhouse/client-web": "^1.12.0", diff --git a/packages/clickhouse-db/vitest.mts b/packages/clickhouse-db/vitest.mts new file mode 100644 index 000000000..c37b0a43f --- /dev/null +++ b/packages/clickhouse-db/vitest.mts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + test: { + environment: "node", + include: ["./**/*.test.ts"], + exclude: ["./**/*.integration.test.ts", "./node_modules/**", "./dist/**"], + reporters: ["verbose"], + passWithNoTests: true, + }, +}); diff --git a/scripts/check-test-tiers.mjs b/scripts/check-test-tiers.mjs index f08a16459..bf587d213 100644 --- a/scripts/check-test-tiers.mjs +++ b/scripts/check-test-tiers.mjs @@ -12,6 +12,8 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { integrationSuites } from "./integration-suites.mjs"; + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const skipDirectories = new Set(["node_modules", "dist", ".git", ".turbo", "build", ".next"]); @@ -76,6 +78,13 @@ const integrationDirectories = new Set( }), ); +// …and needs to be registered with the runner: a package with a valid config +// that is missing from the suite list would pass every check here and still +// never run. +const registeredDirectories = new Set( + integrationSuites.map((suite) => path.resolve(repoRoot, suite.directory)), +); + for (const directory of integrationDirectories) { if (!fs.existsSync(path.join(directory, "vitest.integration.mts"))) { failures.push( @@ -83,6 +92,12 @@ for (const directory of integrationDirectories) { `vitest.integration.mts, so \`pnpm test:integration\` cannot run them.`, ); } + if (!registeredDirectories.has(directory)) { + failures.push( + `${path.relative(repoRoot, directory)}: has *.integration.test.ts files but is not ` + + `registered in scripts/integration-suites.mjs, so \`pnpm test:integration\` never runs it.`, + ); + } } if (failures.length > 0) { diff --git a/scripts/integration-suites.mjs b/scripts/integration-suites.mjs new file mode 100644 index 000000000..b25aee011 --- /dev/null +++ b/scripts/integration-suites.mjs @@ -0,0 +1,17 @@ +// The integration-capable suites, in the order `run-local-integration.mjs` +// executes them. `check-test-tiers.mjs` imports this list too, so a package +// that gains `*.integration.test.ts` files without being registered here fails +// `verify:quick` instead of silently never running — the config-presence check +// alone cannot see this file's coverage. +// +// Every suite runs `vitest.integration.mts`, which selects +// `*.integration.test.ts` and nothing else. Unit files stay with `pnpm test`; +// no suite appears in both. +export const integrationSuites = [ + { name: "platform", directory: "selfhost/platform" }, + { name: "backend", directory: "apps/backend" }, + { name: "core", directory: "packages/core" }, + { name: "backend-smoke", directory: "packages/backend" }, + { name: "agent", directory: "packages/agent" }, + { name: "db", directory: "packages/db" }, +]; diff --git a/scripts/run-local-integration.mjs b/scripts/run-local-integration.mjs index 90716e4a4..d807550e4 100644 --- a/scripts/run-local-integration.mjs +++ b/scripts/run-local-integration.mjs @@ -19,6 +19,8 @@ import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { integrationSuites } from "./integration-suites.mjs"; + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const parseEnvFile = (filePath) => { @@ -138,16 +140,7 @@ const testEnv = { : {}), }; -// Every suite runs `vitest.integration.mts`, which selects `*.integration.test.ts` -// and nothing else. Unit files stay with `pnpm test`; no suite appears in both. -const suites = [ - { name: "platform", directory: "selfhost/platform" }, - { name: "backend", directory: "apps/backend" }, - { name: "core", directory: "packages/core" }, - { name: "backend-smoke", directory: "packages/backend" }, - { name: "agent", directory: "packages/agent" }, - { name: "db", directory: "packages/db" }, -]; +const suites = integrationSuites; const requested = process.argv.slice(2); const selected = requested.length diff --git a/turbo.json b/turbo.json index b55819438..e5f5cb9b6 100644 --- a/turbo.json +++ b/turbo.json @@ -28,9 +28,6 @@ "clean": {}, "format": {}, "lint": {}, - "knip": { - "cache": true - }, "start": { "cache": false }, From 9331c2ebbe8580baf3b329d2a7a4c2c09da693a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Fri, 7 Aug 2026 23:07:56 +0200 Subject: [PATCH 02/10] feat: effect aware linting --- apps/backend/package.json | 1 + apps/backend/src/DurableEntityAlarms.ts | 24 +- apps/backend/src/agent/AgentNodeWebSocket.ts | 120 +- apps/backend/src/backend/Analytics.ts | 101 +- apps/backend/src/backend/Backend.ts | 2 +- apps/backend/src/backend/Background.ts | 7 +- apps/backend/src/backend/Clickhouse.ts | 43 +- apps/backend/src/backend/MimicHost.ts | 116 +- apps/backend/src/backend/ObjectStores.ts | 10 +- apps/backend/src/backend/PlatformProfile.ts | 5 +- .../backend/src/backend/ProjectSchemaCache.ts | 12 +- apps/backend/src/backend/Push.ts | 10 +- apps/backend/src/backend/Thumbnails.ts | 66 +- apps/backend/src/compiler/CompilerClient.ts | 41 +- apps/backend/src/compiler/CompilerCore.ts | 288 ++- apps/backend/src/compiler/main.ts | 135 +- apps/backend/src/config.ts | 129 +- apps/backend/src/migrate.ts | 2 +- apps/backend/src/mimic/MimicNodeWebSocket.ts | 50 +- apps/backend/src/mimic/PgControlStore.ts | 55 +- apps/backend/src/mimic/main.ts | 7 +- apps/backend/src/release-smoke.ts | 39 +- apps/backend/src/server.ts | 100 +- .../AgentNodeWebSocket.integration.test.ts | 482 ++-- .../tests/Analytics.integration.test.ts | 148 +- apps/backend/tests/BackendAdapters.test.ts | 46 +- .../tests/Background.integration.test.ts | 69 +- .../tests/Clickhouse.integration.test.ts | 259 ++- apps/backend/tests/Compiler.test.ts | 87 +- .../tests/CompilerClient.integration.test.ts | 33 +- apps/backend/tests/MimicDocumentIdle.test.ts | 213 +- .../tests/MimicNode.integration.test.ts | 344 +-- apps/backend/tests/MimicNodeWebSocket.test.ts | 218 +- .../tests/PaywallRelease.integration.test.ts | 208 +- apps/backend/tests/Push.integration.test.ts | 102 +- apps/backend/tests/SecurityConfig.test.ts | 5 +- .../tests/StandaloneAuth.integration.test.ts | 156 +- ...StandaloneOrgDirectory.integration.test.ts | 40 +- .../tests/ThumbnailQueue.integration.test.ts | 59 +- apps/backend/tests/Thumbnails.test.ts | 155 +- .../WorkflowComposition.integration.test.ts | 230 +- .../WorkflowRegistry.integration.test.ts | 685 +++--- apps/backend/tests/Www.test.ts | 110 +- apps/cli/build.ts | 56 +- apps/cli/package.json | 1 + apps/cli/src/cli/commands/auth-token.ts | 31 +- apps/cli/src/cli/commands/deploy.ts | 96 +- apps/cli/src/cli/commands/init.ts | 23 +- apps/cli/src/cli/commands/studio.ts | 104 +- apps/cli/src/cli/index.ts | 9 +- apps/cli/src/domain/schema/paywall-deploy.ts | 11 +- apps/cli/src/domain/services/auth.ts | 38 +- apps/cli/src/domain/services/cli-config.ts | 67 +- apps/cli/src/domain/services/codegen.ts | 19 +- apps/cli/src/domain/services/paywall-build.ts | 693 +++--- .../domain/services/paywall-closed-imports.ts | 126 +- .../domain/services/paywall-deploy-upload.ts | 117 +- .../src/domain/services/paywall-typecheck.ts | 153 +- apps/cli/src/domain/services/schema.ts | 33 +- apps/cli/src/domain/services/source-code.ts | 10 +- apps/cli/src/services/auth/index.ts | 4 +- .../src/services/auth/utils/better-auth.ts | 27 +- apps/cli/src/services/cli-config/index.ts | 4 +- apps/cli/src/services/organization/index.ts | 4 +- apps/cli/src/services/project/index.ts | 4 +- apps/cli/src/services/repository/index.ts | 4 +- apps/cli/src/utils/api-client.ts | 12 +- apps/cli/src/utils/error-formatter.ts | 26 +- .../src/utils/js-loading/js-file-loading.ts | 17 +- .../organizations/create-organization.ts | 2 - apps/cli/src/utils/source-code.ts | 6 +- apps/cli/test-monorepo-detection.ts | 0 .../domain/schema/paywall-deploy.test.ts | 35 +- .../services/paywall-closed-imports.test.ts | 282 ++- .../services/paywall-deploy-upload.test.ts | 234 +- .../domain/services/paywall-typecheck.test.ts | 160 +- apps/mimic-admin/package.json | 2 + .../src/components/app-sidebar.tsx | 36 +- .../src/components/auth-context.tsx | 9 +- .../src/components/database-context.tsx | 43 +- .../src/components/sdk-context.tsx | 5 +- apps/mimic-admin/src/components/ui/button.tsx | 10 +- .../src/components/ui/separator.tsx | 3 +- apps/mimic-admin/src/lib/auth.ts | 55 +- .../$collectionId/documents/$documentId.tsx | 68 +- .../collections/$collectionId/index.tsx | 220 +- .../collections/$collectionId/schema.tsx | 16 +- .../src/routes/_app/_layout/databases.tsx | 123 +- .../src/routes/_app/_layout/index.tsx | 8 +- .../src/routes/_app/_layout/users.tsx | 135 +- apps/mimic-admin/src/routes/_app/route.tsx | 5 +- apps/mimic-admin/src/routes/login.tsx | 58 +- apps/mimic-db/package.json | 1 + apps/mimic-db/src/api/handlers/databases.ts | 18 +- .../src/api/handlers/document-auth.ts | 48 +- apps/mimic-db/src/api/handlers/documents.ts | 18 +- apps/mimic-db/src/api/handlers/grants.ts | 18 +- apps/mimic-db/src/api/handlers/users.ts | 18 +- apps/mimic-db/src/api/middleware/auth.ts | 53 +- apps/mimic-db/src/config.ts | 9 +- apps/mimic-db/src/core/control-engine.ts | 119 +- apps/mimic-db/src/core/document-engine.ts | 32 +- apps/mimic-db/src/core/local-entity-host.ts | 2 +- apps/mimic-db/src/core/local-host-service.ts | 66 +- apps/mimic-db/src/core/memory-store.ts | 12 +- apps/mimic-db/src/core/migration-registry.ts | 31 +- apps/mimic-db/src/core/pg-store.ts | 97 +- apps/mimic-db/src/document/schema.ts | 65 +- apps/mimic-db/src/document/transaction.ts | 25 +- .../src/entrypoints/standalone/main.ts | 17 +- .../src/worker/durable-host-service.ts | 69 +- apps/mimic-db/src/ws/document-session.ts | 5 +- apps/mimic-db/src/ws/protocol.ts | 43 +- apps/mimic-db/src/ws/session-registry.ts | 21 +- .../tests/durable-entity-host.test.ts | 151 +- apps/mimic-db/tests/helpers.ts | 44 +- .../tests/integration/host-flow.test.ts | 27 +- .../tests/unit/direct-migration.test.ts | 321 +-- .../mimic-db/tests/unit/document-auth.test.ts | 22 +- .../tests/unit/document-session.test.ts | 394 ++-- apps/mimic-db/tests/unit/idle-notify.test.ts | 155 +- .../tests/unit/migration-registry.test.ts | 98 +- apps/mimic-db/tests/unit/pg-store.test.ts | 16 +- apps/mimic-db/vitest.mts | 10 +- apps/studio/package.json | 3 + apps/studio/src/App.tsx | 37 +- .../src/components/PreviewErrorBoundary.tsx | 3 +- apps/studio/src/components/Sidebar.tsx | 112 +- apps/studio/src/main.tsx | 23 +- apps/studio/src/server/config.ts | 9 +- apps/studio/src/server/index.ts | 46 +- .../src/server/virtual-paywalls-plugin.ts | 92 +- apps/studio/src/voidhash/paywalls.ts | 35 +- apps/studio/src/voidhash/preview-runtime.ts | 3 +- apps/studio/vite.config.ts | 12 +- apps/www/scripts/generate-openapi.ts | 64 +- .../src/components/default-catch-boundary.tsx | 2 +- .../components/standalone-sign-in-form.tsx | 11 +- .../auth/lib/email-verification-storage.ts | 38 +- apps/www/src/features/auth/lib/http.ts | 15 +- .../src/features/auth/lib/provision-user.ts | 15 +- .../features/auth/lib/standalone-session.ts | 13 +- apps/www/src/features/auth/lib/validation.ts | 39 +- .../features/docs/components/layout/docs.tsx | 6 +- .../src/features/docs/lib/mdx-components.tsx | 3 +- apps/www/src/features/docs/lib/source.ts | 29 +- apps/www/src/features/docs/lib/tabs.test.ts | 16 +- .../features/studio/account/user-avatar.tsx | 4 +- apps/www/src/features/studio/ai/agent-ui.ts | 11 +- .../www/src/features/studio/ai/attachments.ts | 35 +- .../studio/ai/components/chat-shell.tsx | 74 +- .../studio/ai/components/tool-call.tsx | 13 +- .../features/studio/ai/use-agent-session.ts | 15 +- .../analytics/custom-dashboards-page.tsx | 26 +- .../studio/analytics/custom-insights-page.tsx | 4 +- .../studio/api-keys/api-key-record.tsx | 6 +- .../api-keys/create-secret-key-modal.tsx | 2 +- .../studio/components/auth-context.tsx | 3 +- .../studio/components/avatar-uploader.tsx | 101 +- .../components/default-cache-boundary.tsx | 2 +- .../studio/components/property-list.tsx | 2 +- .../studio/enterprise/runtime-capabilities.ts | 40 +- .../experiment-draft-context.tsx | 7 +- .../experiment-lifecycle-controls.tsx | 4 +- .../create-experiment-modal.tsx | 2 +- .../flag-detail-actions-menu.tsx | 2 +- .../flag-detail-page/flag-draft-context.tsx | 25 +- .../flags-page/create-flag-modal.tsx | 31 +- .../person-flag-overrides-panel.tsx | 2 +- .../lib/payment-providers/google-play.ts | 40 +- .../studio/lib/tanstack-query/api-keys.ts | 1 - .../payment-provider-configurations.ts | 1 - .../payment-provider-products.ts | 1 - .../lib/tanstack-query/paywall-locations.ts | 1 - .../studio/lib/tanstack-query/perks.ts | 1 - .../lib/tanstack-query/product-perks.ts | 1 - .../studio/lib/tanstack-query/products.ts | 1 - .../studio/lib/tanstack-query/projects.ts | 1 - .../studio/lib/tanstack-query/webhooks.ts | 1 - apps/www/src/features/studio/lib/zod-error.ts | 28 +- .../create-organization-modal.tsx | 2 +- .../overview/date-range-filter.tsx | 2 - .../organizations/overview/today-chart.tsx | 3 - .../organizations/settings/data-table.tsx | 2 +- .../settings/general/team-avatar.tsx | 4 +- .../settings/general/team-delete.tsx | 4 +- .../settings/general/team-name.tsx | 2 +- .../studio/paywall-assets/asset-library.tsx | 33 +- .../studio/paywall-assets/asset-tile.tsx | 16 +- .../studio/paywall-assets/upload-image.ts | 57 +- .../create-paywall-location-modal.tsx | 2 +- .../shared/edit-paywall-location-modal.tsx | 2 +- .../paywalls-page/create-paywall-modal.tsx | 2 +- .../paywalls-page/paywall-view-settings.tsx | 25 +- .../designer/canvas/bounding-box-manager.tsx | 5 +- .../canvas/overlay/selection-overlay.tsx | 3 +- .../working-indicator-animation.test.ts | 2 +- .../overlay/working-indicator-overlay.tsx | 2 +- .../designer/canvas/preview-canvas.test.ts | 3 +- .../paywalls/designer/canvas/viewport.tsx | 3 +- .../code-mode/code-editor-context.tsx | 3 +- .../designer/code-mode/code-editor-pane.tsx | 46 +- .../code-mode/code-mode-workspace.tsx | 7 +- .../designer/code-mode/compile-pipeline.ts | 124 +- .../designer/code-mode/sandbox-host.ts | 101 +- .../components/designer-context-menu.tsx | 43 +- .../designer/dev-mode/json-tree-viewer.tsx | 37 +- .../designer/dev-mode/node-details-panel.tsx | 5 +- .../designer/hooks/use-designer-draft.ts | 19 +- .../designer/hooks/use-keyboard-shortcuts.ts | 18 +- .../hooks/use-paywall-component-catalog.ts | 45 +- .../designer/panel-kit/alignment-grid.tsx | 3 +- .../designer/panel-kit/color-input.tsx | 2 +- .../color-picker/eyedropper-button.tsx | 22 +- .../designer/panel-kit/text-input.tsx | 15 +- .../panel-runtime/gesture-controller.test.ts | 77 +- .../panel-runtime/host-renderer.test.tsx | 3 +- .../designer/panel-runtime/host-renderer.tsx | 15 +- .../in-process-transport.test.tsx | 111 +- .../panel-runtime/in-process-transport.ts | 51 +- .../panel-runtime/intent-executor.test.ts | 2 +- .../panel-runtime/panel-sandbox-driver.ts | 74 +- .../panel-runtime/panel-sandbox-host.test.ts | 375 +-- .../panel-runtime/panel-sandbox-host.ts | 3 - .../panel-runtime/sandbox-messages.ts | 51 +- .../paywalls/designer/panel-runtime/schema.ts | 47 +- .../panels/left-panel/layers-section.tsx | 4 - .../designer/panels/panel-width-storage.ts | 63 +- .../right-panel/builtin-panel-host.test.tsx | 45 +- .../right-panel/catalog-panel-code.test.ts | 46 +- .../panels/right-panel/catalog-panel-code.ts | 46 +- .../right-panel/component-panel-host.test.tsx | 17 +- .../component-settings-panel.test.tsx | 2 +- .../definitions/definition-selection.tsx | 27 +- .../definitions/path-fill-panel.test.tsx | 9 +- .../testing/definition-harness.tsx | 19 +- .../fixtures/panel-component-fixture.test.tsx | 43 +- .../definitions/variables-panel.test.tsx | 45 +- .../right-panel/panel-host-services-host.tsx | 11 +- .../paywalls/designer/panels/top-panel.tsx | 4 +- .../designer/state/actions/canvas-actions.ts | 2 +- .../actions/code-component-actions.test.ts | 7 +- .../actions/component-catalog-actions.ts | 1 - ...mponent-preview-state-selection-actions.ts | 1 - .../designer/state/actions/debug-actions.ts | 1 - .../state/actions/dev-mode-actions.ts | 2 +- .../state/actions/drag-select-actions.ts | 1 - .../features/component-prop-actions.test.ts | 3 +- .../features/fill-gradient-writes.test.ts | 7 +- .../actions/features/fill-render.test.ts | 5 +- .../features/interaction-actions.test.ts | 19 +- .../actions/features/locale-actions.test.ts | 9 +- .../actions/features/state-actions.test.ts | 13 +- .../state/actions/features/state-actions.ts | 1 - .../actions/features/variable-actions.test.ts | 11 +- .../actions/features/variable-actions.ts | 11 +- .../state/actions/move-actions.test.ts | 13 +- .../designer/state/actions/node-actions.ts | 106 +- .../actions/nodes/component-node-actions.ts | 71 +- .../state/actions/nodes/path-node-actions.ts | 11 +- .../actions/nodes/screen-node-actions.ts | 11 +- .../actions/nodes/scrollview-node-actions.ts | 15 +- .../state/actions/nodes/shape-node-actions.ts | 83 +- .../state/actions/nodes/text-node-actions.ts | 11 +- .../actions/nodes/view-node-actions.test.ts | 3 +- .../state/actions/nodes/view-node-actions.ts | 15 +- .../designer/state/actions/panel-actions.ts | 1 - .../state/actions/resize-actions.test.ts | 7 +- .../designer/state/actions/resize-actions.ts | 2 +- .../state/actions/selection-actions.ts | 1 - .../state-override-selection-actions.ts | 1 - .../designer/state/actions/tools-actions.ts | 2 +- .../context-menu/actions/clipboard-actions.ts | 6 +- .../designer/state/context-menu/types.ts | 1 - .../designer/state/designer-store.tsx | 65 +- .../state/testing/offline-document.ts | 5 +- .../state/utils/code-component-writes.ts | 12 +- .../state/utils/component-node-writes.test.ts | 22 +- .../designer/state/utils/document-root.ts | 18 +- .../designer/state/utils/localization.test.ts | 9 +- .../state/utils/node-data-writes.test.ts | 17 +- .../paywalls/designer/state/utils/replay.ts | 55 +- .../paywalls/designer/utils/locale-display.ts | 12 +- .../studio/perks/create-perk-modal.tsx | 2 +- .../src/features/studio/perks/perk-record.tsx | 4 +- .../studio/persons/create-person-modal.tsx | 2 +- .../persons/persons-table/data-table.tsx | 2 +- .../studio/products/create-product-modal.tsx | 2 +- .../studio/products/edit-product-modal.tsx | 2 +- .../product-detail-add-perk-button.tsx | 2 +- .../studio/products/product-detail-pane.tsx | 2 +- .../product-detail-product-perk-record.tsx | 2 +- .../studio/projects/create-project-modal.tsx | 4 +- .../settings/general/project-avatar.tsx | 4 +- .../settings/general/project-delete.tsx | 4 +- .../settings/general/project-name.tsx | 2 +- .../fcm-notification-provider-detail-page.tsx | 2 - .../setup-notification-provider-button.tsx | 2 +- ...se-notification-configuration-mutations.ts | 2 +- .../payment-providers-new-store-dropdown.tsx | 2 +- ...gle-play-payment-provider-detail-config.ts | 2 +- .../stripe-payment-provider-detail-config.ts | 6 +- .../setup-payment-provider-button.tsx | 2 +- ...ayment-provider-configuration-mutations.ts | 2 +- .../dashboard-sidebar-provider.tsx | 11 - .../components/dashboard-sidebar/index.ts | 1 - .../environment-filter-notification.tsx | 2 +- .../nav-bar/nav-user/nav-user-dropdown.tsx | 3 +- .../sidebar/project-settings-sidebar.tsx | 112 - .../studio/webhooks/create-webhook-modal.tsx | 2 +- .../studio/webhooks/edit-webhook-modal.tsx | 2 +- .../webhook-endpoint-actions-dropdown.tsx | 8 +- .../webhook-endpoint-detail-sidebar.tsx | 2 +- .../webhooks/webhook-endpoint-record.tsx | 6 +- .../webhooks/webhook-payload-viewer.tsx | 2 +- .../webhooks/webhook-secret-reveal-modal.tsx | 2 +- .../src/features/www/marketing-home-slot.tsx | 5 +- apps/www/src/lib/effect-query.ts | 22 +- apps/www/src/routes/auth/devices/index.tsx | 62 +- apps/www/src/routes/docs/$.tsx | 28 +- .../_organization/$organizationSlug/route.tsx | 34 +- .../$projectSlug/activity.events.tsx | 17 +- .../$projectSlug/analytics.query.tsx | 8 +- .../$projectSlug/experiments.$id.tsx | 3 +- .../$projectSlug/experiments.index.tsx | 3 +- .../$projectSlug/flags.$id.tsx | 3 +- .../$projectSlug/flags.index.tsx | 3 +- .../$organizationSlug/$projectSlug/index.tsx | 2 +- .../$projectSlug/paywalls.$id.tsx | 3 +- .../$projectSlug/paywalls.index.tsx | 3 +- .../$projectSlug/persons.$id.tsx | 3 +- .../$projectSlug/products.$id.tsx | 3 +- .../$projectSlug/products.index.tsx | 3 +- .../$organizationSlug/$projectSlug/route.tsx | 34 +- .../$projectSlug/settings/api-keys.tsx | 3 +- .../$projectSlug/settings/index.tsx | 3 +- ...notifications.$providerConfigurationId.tsx | 3 +- .../settings/notifications.index.tsx | 3 +- ...viders.$paymentProviderConfigurationId.tsx | 3 +- .../settings/payment-providers.index.tsx | 3 +- .../settings/paywall-locations.$id.tsx | 3 +- .../settings/paywall-locations.index.tsx | 3 +- .../$projectSlug/settings/perks.tsx | 3 +- .../webhooks.$endpointId.$deliveryId.tsx | 4 +- .../settings/webhooks.$endpointId.index.tsx | 5 +- .../$projectSlug/settings/webhooks.index.tsx | 3 +- ...ganizationSlug.$projectSlug.design.$id.tsx | 4 +- .../create-organization/index.tsx | 5 +- .../routes/studio/_authenticated/route.tsx | 66 +- apps/www/src/start.ts | 17 +- .../src/components/kanban/Card.tsx | 10 +- .../src/components/kanban/Column.tsx | 85 +- .../src/components/kanban/EditCardModal.tsx | 2 +- .../src/components/kanban/KanbanBoard.tsx | 67 +- .../src/context/KanbanContext.tsx | 5 +- examples/mimic-example/src/lib/commands.ts | 25 +- examples/mimic-example/src/lib/document.ts | 78 +- examples/mimic-example/src/lib/store.ts | 28 +- examples/mimic-example/src/posts.tsx | 72 +- examples/mimic-example/src/server/app.ts | 104 +- examples/mimic-example/src/server/index.ts | 20 +- .../.voidhash/components/product-option.tsx | 18 +- .../.voidhash/paywalls/onboarding.tsx | 11 +- examples/react-native-example/app-env.d.ts | 9 + examples/react-native-example/app/index.tsx | 21 +- .../app/menu/customer.tsx | 23 +- .../react-native-example/app/menu/paywall.tsx | 113 +- .../react-native-example/app/menu/sign-in.tsx | 24 +- .../components/button.tsx | 2 +- .../react-native-example/components/logo.tsx | 8 +- .../components/menu-item.tsx | 6 +- .../{eslint.config.js => eslint.config.mjs} | 6 +- examples/react-native-example/package.json | 1 + .../utils/fake-auth-service.ts | 86 +- .../utils/voidhash/client.ts | 48 +- libraries/node/build.ts | 71 +- libraries/node/src/effect-client.ts | 2 +- libraries/node/src/errors.ts | 2 +- .../node/src/internal/filter-sdk-group.ts | 10 +- .../node/src/internal/json-compatible-api.ts | 26 +- .../src/internal/make-generated-client.ts | 78 +- libraries/node/src/promise-client.ts | 41 +- libraries/node/tests/client.test.ts | 522 +++-- libraries/node/tests/helpers.ts | 98 +- libraries/paywalls/src/panel/session.tsx | 2 +- libraries/paywalls/src/renderer/dom-drag.ts | 2 +- libraries/paywalls/src/renderer/dom-host.tsx | 18 +- libraries/paywalls/src/renderer/dom-motion.ts | 1 - libraries/paywalls/src/runtime/bridge.ts | 4 +- .../paywalls/src/schema/validate-panel.ts | 2 +- libraries/paywalls/src/schema/validate.ts | 2 +- .../src/tree-renderer/render-to-node-tree.tsx | 6 +- .../callable-component-manifest.test.tsx | 1 - .../tests/runtime-environment.test.tsx | 16 +- libraries/paywalls/tests/sandbox.test.tsx | 2 +- libraries/react-native/.eslintrc.old.js | 2 - libraries/react-native/package.json | 1 + libraries/react-native/src/client-effect.ts | 10 +- .../react-native/src/client-react-native.ts | 3 +- libraries/react-native/src/client.tsx | 42 +- .../src/core/analytics/service.ts | 10 +- libraries/react-native/src/core/errors.ts | 130 -- .../feature-flags/feature-flag-service.ts | 2 +- .../react-native-lifecycle-adapter.ts | 26 +- .../react-native/src/core/logging/logger.ts | 19 +- .../src/core/networking/api-client.ts | 2 +- .../src/core/networking/http-debug-client.ts | 41 +- .../react-native-platform-provider.ts | 15 +- .../src/core/testing/cache-adapter.ts | 16 - .../react-native/src/core/testing/client.ts | 89 - libraries/react-native/src/core/utils.ts | 13 +- .../src/core/utils/account-token.ts | 3 +- .../src/internal/paywall-bridge/parser.ts | 129 +- .../src/internal/webview/PaywallWebView.tsx | 7 +- .../src/react/components/provider.tsx | 7 +- .../src/react/hooks/use-async-function.ts | 42 +- .../react-native/src/react/hooks/use-fetch.ts | 115 - .../react/hooks/use-paywall-by-location.ts | 148 +- .../src/react/hooks/use-products.ts | 2 +- .../tests/core/cache-manager.test.ts | 183 +- .../tests/core/client-effect.test.ts | 1324 ++++++----- .../tests/core/identity-manager.test.ts | 236 +- .../core/native-purchase-contract.test.ts | 26 +- .../tests/core/person-info-manager.test.ts | 206 +- .../tests/core/schema-manager.test.ts | 317 +-- .../tests/helpers/effect-test-harness.ts | 11 +- .../react/use-paywall-by-location.test.tsx | 64 +- libraries/web/build.ts | 77 +- libraries/web/src/client-effect.ts | 38 +- libraries/web/src/client.ts | 277 +-- .../src/core/analytics/analytics-context.ts | 35 +- .../src/core/analytics/analytics-service.ts | 195 +- .../caching/adapters/browser-cache-adapter.ts | 110 +- .../web/src/core/caching/cache-manager.ts | 77 +- .../feature-flags/feature-flag-service.ts | 56 +- .../web/src/core/identity/identity-manager.ts | 57 +- .../web/src/core/networking/api-client.ts | 41 +- .../networking/event-capture-api-client.ts | 9 +- .../core/networking/json-compatible-api.ts | 28 +- .../platform/browser-platform-provider.ts | 138 +- libraries/web/src/errors.ts | 2 +- .../web/src/react/hooks/use-feature-flags.ts | 94 +- libraries/web/src/react/hooks/use-voidhash.ts | 5 +- libraries/web/src/react/provider.tsx | 23 +- libraries/web/src/types.ts | 2 +- libraries/web/tests/analytics.test.ts | 340 +-- libraries/web/tests/client.test.ts | 551 ++--- libraries/web/tests/helpers.ts | 101 +- libraries/web/tests/react.test.tsx | 197 +- package.json | 3 + packages/agent/package.json | 2 + packages/agent/src/AgentSessionCore.ts | 384 ++-- packages/agent/src/AgentToolAdapter.ts | 83 +- packages/agent/src/EffectRunner.ts | 12 +- packages/agent/src/Protocol.ts | 16 +- packages/agent/src/SessionLog.ts | 102 +- packages/agent/src/SkillSource.ts | 32 +- packages/agent/src/index.ts | 31 +- .../AgentSessionCluster.integration.test.ts | 304 +-- packages/agent/tests/AgentSessionCore.test.ts | 457 ++-- packages/agent/tests/AgentToolAdapter.test.ts | 181 +- packages/agent/tests/EffectRunner.test.ts | 65 +- packages/agent/tests/Protocol.test.ts | 12 +- packages/agent/tests/SessionLog.test.ts | 95 +- packages/agent/tests/SkillSource.test.ts | 38 +- packages/agent/tests/run-workerd-do-probe.mjs | 346 +-- packages/agent/tests/workerd-do-probe.ts | 201 +- packages/agent/tests/workerd-probe.ts | 95 +- packages/ai-shared/package.json | 1 + .../src/apply-document-edits.test.ts | 41 +- .../ai-shared/src/apply-document-edits.ts | 11 +- packages/ai-shared/src/document-edits.test.ts | 108 +- packages/ai-shared/src/document-edits.ts | 149 +- .../ai-shared/src/document-serializer.test.ts | 73 +- packages/ai-shared/src/document-serializer.ts | 102 +- packages/ai-shared/src/mimic-introspection.ts | 121 +- .../ai-shared/src/style-group-flags.test.ts | 2 +- packages/ai-shared/src/style-group-flags.ts | 20 +- packages/app-store-server-sdk/package.json | 2 + .../src/client/AppStoreServerSdkClient.ts | 205 +- .../app-store-server-sdk/src/client/auth.ts | 204 +- .../src/errors/api-errors.ts | 5 +- .../src/errors/verification-errors.ts | 5 +- .../src/internal/bytes.ts | 53 +- .../src/receipts/index.ts | 28 +- .../app-store-server-sdk/src/schemas/enums.ts | 197 +- .../src/schemas/helper-validation.ts | 16 +- .../src/signatures/index.ts | 64 +- .../src/verification/SignedDataVerifier.ts | 65 +- .../src/verification/certificate-chain.ts | 80 +- .../tests/advanced-commerce.test.ts | 26 +- .../tests/api-client.test.ts | 2003 +++++++++-------- .../tests/import-ban.test.ts | 69 +- .../tests/jws-signature-tamper.test.ts | 62 +- .../tests/jws-verification.test.ts | 238 +- .../tests/realtime.test.ts | 48 +- .../tests/receipts.test.ts | 107 +- .../tests/signatures.test.ts | 313 +-- .../tests/transaction-decoding.test.ts | 702 +++--- packages/app-store-server-sdk/tests/util.ts | 125 +- .../tests/verification.test.ts | 281 +-- .../workerd-smoke/worker.ts | 71 +- packages/backend/package.json | 1 + packages/backend/src/ApiMiddlewares.ts | 17 +- packages/backend/src/AuthSessionResolver.ts | 24 +- packages/backend/src/BackendApp.ts | 138 +- .../src/GooglePubSubPushVerifier.test.ts | 208 +- .../backend/src/GooglePubSubPushVerifier.ts | 23 +- .../src/PaywallSnapshotHtmlRenderer.test.ts | 16 +- .../src/PaywallSnapshotHtmlRenderer.ts | 76 +- packages/backend/src/Telemetry.test.ts | 19 +- packages/backend/src/Telemetry.ts | 3 +- .../src/ai/AgentSessionIndexAdapter.test.ts | 62 +- packages/backend/src/ai/DesignerContext.ts | 44 +- .../backend/src/ai/WorkspaceAgentModels.ts | 5 +- .../ai/WorkspaceAgentSessionFactory.test.ts | 167 +- .../src/ai/WorkspaceAgentSessionFactory.ts | 106 +- .../src/ai/WorkspaceAgentTools.test.ts | 93 +- .../backend/src/ai/WorkspaceAgentTools.ts | 122 +- .../src/ai/skills/component-authoring.test.ts | 43 +- .../src/ai/skills/paywall-authoring.test.ts | 7 +- .../src/ai/skills/paywall-authoring.ts | 34 +- packages/backend/src/ai/skills/registry.ts | 3 +- packages/backend/src/ai/surfaces.ts | 8 +- packages/backend/src/ai/tools.test.ts | 1321 ++++++----- packages/backend/src/ai/vfs/bash-tool.test.ts | 193 +- packages/backend/src/ai/vfs/bash-tool.ts | 90 +- .../backend/src/ai/vfs/readonly-fs.test.ts | 187 +- packages/backend/src/ai/vfs/readonly-fs.ts | 270 ++- packages/backend/src/ai/vfs/workspace-vfs.ts | 186 +- packages/backend/src/ai/workspace-tools.ts | 595 +++-- packages/backend/src/mcp/dispatch.test.ts | 366 +-- packages/backend/src/mcp/protocol.test.ts | 348 ++- packages/backend/src/mcp/protocol.ts | 116 +- .../backend/src/mcp/tool-manifest.test.ts | 134 +- packages/backend/src/mcp/tool-manifest.ts | 7 +- packages/backend/src/routes/event-capture.ts | 13 +- packages/backend/src/routes/mcp-oauth.test.ts | 49 +- packages/backend/src/routes/mcp-oauth.ts | 34 +- packages/backend/src/routes/mcp.ts | 90 +- .../src/routes/paywall-serving.test.ts | 301 +-- .../backend/src/routes/paywall-serving.ts | 11 +- .../src/routes/public-file-serving.test.ts | 77 +- .../backend/src/routes/public-file-serving.ts | 5 +- packages/backend/src/routes/v1/api-keys.ts | 10 +- packages/backend/src/routes/v1/auth.ts | 19 +- .../routes/v1/payment-provider-products.ts | 10 +- packages/backend/src/routes/v1/persons.ts | 5 +- packages/backend/src/routes/v1/schema.ts | 10 +- packages/backend/src/routes/v1/sdk.ts | 102 +- .../apple-server-to-server.ts | 5 +- .../google-play-rtdn.test.ts | 54 +- .../webhook-endpoints/google-play-rtdn.ts | 8 +- .../src/routes/webhook-endpoints/stripe.ts | 15 +- .../backend/src/rpc-smoke.integration.test.ts | 189 +- .../src/rpcs/agent-session-rpcs.test.ts | 141 +- .../backend/src/rpcs/agent-session-rpcs.ts | 33 +- packages/backend/src/rpcs/analytics-rpcs.ts | 8 +- packages/backend/src/rpcs/experiment-rpcs.ts | 31 +- .../payment-provider-configuration-rpcs.ts | 24 +- .../src/rpcs/payment-provider-product-rpcs.ts | 30 +- .../src/rpcs/paywall-workspace-rpcs.test.ts | 183 +- .../src/rpcs/paywall-workspace-rpcs.ts | 12 +- packages/backend/src/rpcs/person-rpcs.ts | 5 +- .../push-notification-configuration-rpcs.ts | 24 +- packages/backend/src/rpcs/voidql-rpcs.ts | 4 - .../src/security/authorization-matrix.test.ts | 63 +- .../src/testing/PurchaseSdkHttpHarness.ts | 26 +- packages/backend/src/testing/TestLayers.ts | 223 +- packages/backend/src/testing/TestRpcAuth.ts | 3 +- .../backend/src/testing/rpc-smoke-cases.ts | 59 +- packages/backend/src/testing/smoke-ids.ts | 12 +- packages/backend/src/testing/smoke-seed.ts | 18 +- packages/clickhouse-db/package.json | 3 +- .../clickhouse-db/src/analytics/migration.ts | 6 +- .../clickhouse-db/src/analytics/schema.ts | 10 +- .../ClickhouseWebClient.ts | 279 ++- packages/clickhouse-db/src/live.ts | 18 +- .../0002_create_analytics_identity_v2.ts | 6 +- ...analytics_identity_pending_overrides_v2.ts | 6 +- .../0004_person_identity_cutover.ts | 4 +- .../0005_align_analytics_identity_tables.ts | 38 +- ..._add_organization_id_to_identity_tables.ts | 8 +- .../0008_repair_identity_organization_id.ts | 8 +- .../core/src/domain/analytics/Analytics.ts | 166 +- .../domain/analyticsIngest/AnalyticsIngest.ts | 166 +- packages/core/src/domain/auth/Auth.ts | 4 +- packages/core/src/domain/avatar.ts | 55 +- .../InternalAnalyticsEvents.ts | 10 +- .../core/src/domain/paywallAssetImage.test.ts | 27 +- packages/core/src/domain/paywallAssetImage.ts | 55 +- packages/core/src/domain/paywallThumbnail.ts | 5 +- .../core/src/domain/person/IdentityGraph.ts | 48 +- packages/core/src/domain/person/Person.ts | 14 +- .../AgentAttachmentService.test.ts | 49 +- .../agentSession/AgentAttachmentService.ts | 3 +- .../AgentSessionIndexService.test.ts | 36 +- .../agentSession/AgentSessionIndexService.ts | 63 +- .../services/analytics/AnalyticsService.ts | 73 +- .../analytics/CustomAnalyticsService.ts | 1081 +++++---- .../services/analytics/clickhouse-accessor.ts | 747 +++--- .../src/services/analytics/series-resolver.ts | 30 +- .../AnalyticsDispatchService.ts | 62 +- .../AnalyticsIngestDlqService.ts | 49 +- .../AnalyticsJanitorService.ts | 47 +- .../analyticsIngest/AnalyticsWriterService.ts | 47 +- .../services/analyticsIngest/DlqProducer.ts | 22 +- .../analyticsIngest/EventCaptureService.ts | 146 +- .../analyticsIngest/EventProcessorService.ts | 368 +-- .../src/services/apiKeys/ApiKeyService.ts | 43 +- .../core/src/services/apiKeys/api-keys.ts | 30 +- .../core/src/services/apiKeys/create-hash.ts | 90 +- .../services/auditLog/AuditLogPort.test.ts | 14 +- .../src/services/auth/AuthTokenVerifier.ts | 24 +- .../auth/IdentityLinkBackfillService.test.ts | 149 +- .../auth/IdentityLinkBackfillService.ts | 15 +- .../services/auth/LocalUserSessionService.ts | 43 +- .../auth/StandaloneIdentityProvider.ts | 25 +- .../services/experiments/ExperimentService.ts | 89 +- .../featureFlags/FeatureFlagService.ts | 160 +- .../src/services/feedback/FeedbackService.ts | 79 +- .../src/services/fxRates/FxRateService.ts | 19 +- .../fxRates/exchange-rate-api-fetcher.ts | 121 +- .../InternalFeatureFlagService.ts | 19 +- .../ApplePushNotificationService.ts | 191 +- .../FirebaseCloudMessagingService.ts | 373 +-- .../NotificationSendingService.ts | 73 +- .../notifications/NotificationTokenService.ts | 105 +- .../NotificationsConfigurationService.ts | 46 +- .../PersonNotificationTokenService.ts | 67 +- .../notifications/PushDeliveryService.ts | 136 +- .../PushNotificationSendService.ts | 5 +- .../organizations/OrganizationService.ts | 18 +- .../organizations/StandaloneOrgDirectory.ts | 15 +- .../PaymentProviderConfigurationService.ts | 42 +- .../PaymentProviderProductService.ts | 116 +- .../app-store-reconciliation-service.ts | 78 +- .../app-store-webhook-handler-service.ts | 135 +- .../appStore/config-provider.test.ts | 167 +- .../appStore/config-provider.ts | 15 +- .../paymentProviders/appStore/helpers.ts | 14 +- .../paymentProviders/appStore/money.test.ts | 168 +- .../paymentProviders/appStore/money.ts | 26 +- .../payment-provider-service-queries.ts | 5 +- .../appStore/payment-provider-service.ts | 73 +- .../appStore/payment-provider.ts | 169 +- .../appStore/transaction-verifier.ts | 23 +- .../googlePlay/config-provider.test.ts | 188 +- .../googlePlay/config-provider.ts | 44 +- .../googlePlay/helpers.test.ts | 15 +- .../paymentProviders/googlePlay/helpers.ts | 13 +- .../paymentProviders/googlePlay/money.test.ts | 4 +- .../googlePlay/notifications.test.ts | 12 +- .../googlePlay/notifications.ts | 60 +- .../payment-provider-service-queries.ts | 5 +- .../googlePlay/payment-provider-service.ts | 26 +- .../googlePlay/payment-provider.ts | 66 +- .../googlePlay/sdk-context.ts | 7 +- .../googlePlay/webhook-handler-service.ts | 162 +- .../payment-provider-adapter.ts | 7 +- .../stripe/config-provider.test.ts | 257 ++- .../stripe/config-provider.ts | 29 +- .../paymentProviders/stripe/events.ts | 9 +- .../paymentProviders/stripe/helpers.test.ts | 50 +- .../paymentProviders/stripe/helpers.ts | 16 +- .../paymentProviders/stripe/money.test.ts | 28 +- .../services/paymentProviders/stripe/money.ts | 13 +- .../payment-provider-service-queries.ts | 10 +- .../stripe/payment-provider-service.ts | 18 +- .../stripe/payment-provider.ts | 77 +- .../stripe/sdk-context.test.ts | 202 +- .../paymentProviders/stripe/sdk-context.ts | 99 +- .../stripe/stripe-webhook-handler-service.ts | 121 +- .../paywallAssets/PaywallAssetService.ts | 4 +- .../PaywallDeployManifest.contract.test.ts | 131 +- .../paywallDeploys/PaywallDeployManifest.ts | 46 +- .../PaywallDeployManifest.unit.test.ts | 50 +- .../paywallDeploys/PaywallDeployService.ts | 114 +- .../PaywallDeployService.unit.test.ts | 505 +++-- .../PaywallLocationService.ts | 175 +- .../src/services/paywallLocations/helpers.ts | 69 +- .../paywallReleases/PaywallReleaseService.ts | 32 +- .../PaywallThumbnailService.test.ts | 621 ++--- .../PaywallThumbnailService.ts | 45 +- .../inlinePublicFileImages.test.ts | 167 +- .../inlinePublicFileImages.ts | 41 +- .../ComponentManifestCacheService.test.ts | 322 +-- .../ComponentManifestCacheService.ts | 47 +- .../PaywallEditSessionService.test.ts | 475 ++-- .../PaywallEditSessionService.ts | 56 +- .../PaywallWorkspaceService.test.ts | 876 +++---- .../PaywallWorkspaceService.ts | 71 +- .../src/services/paywalls/PaywallService.ts | 12 +- .../services/perkGrants/PerkGrantService.ts | 66 +- .../core/src/services/perks/PerkService.ts | 5 +- .../personIdentity/IdentityMutationService.ts | 105 +- .../IdentityProjectionPublisher.ts | 49 +- .../IdentityProjectionRebuildService.ts | 8 +- .../personIdentity/PersonIdentityService.ts | 63 +- .../src/services/persons/PersonService.ts | 18 +- .../productPerks/ProductPerkService.ts | 18 +- .../src/services/products/ProductService.ts | 17 +- .../core/src/services/products/helpers.ts | 11 +- .../src/services/projects/ProjectService.ts | 6 +- .../PurchaseLedgerWorkerService.ts | 28 +- .../PurchaseProcessingService.ts | 236 +- .../services/purchaseProcessing/helpers.ts | 8 +- .../purchaseProcessing/result-codec.ts | 27 +- .../revenue-analytics-mapper.ts | 7 +- .../src/services/purchases/PurchaseService.ts | 3 +- .../schema/SchemaCacheInvalidationService.ts | 3 +- .../core/src/services/schema/SchemaService.ts | 15 +- packages/core/src/services/schema/helpers.ts | 18 +- packages/core/src/services/sdk/SdkService.ts | 93 +- .../core/src/services/sdk/elevate-auth.ts | 20 +- .../core/src/services/sdk/snapshot-builder.ts | 63 +- .../core/src/services/slack/slack-client.ts | 107 +- .../core/src/services/users/UserService.ts | 28 +- .../core/src/services/voidql/VoidQlService.ts | 21 +- .../core/src/services/voidql/catalog/brand.ts | 4 +- .../src/services/voidql/catalog/revenue.ts | 6 +- packages/core/src/services/voidql/compile.ts | 24 +- packages/core/src/services/voidql/compiler.ts | 211 +- packages/core/src/services/voidql/errors.ts | 26 +- packages/core/src/services/voidql/ir.ts | 13 +- packages/core/src/services/voidql/lexer.ts | 30 +- packages/core/src/services/voidql/parser.ts | 189 +- packages/core/src/services/voidql/scope.ts | 19 +- packages/core/src/services/voidql/verify.ts | 9 +- .../webhookDispatch/WebhookDeliveryService.ts | 149 +- .../webhookDispatch/WebhookDispatchService.ts | 13 +- .../webhookManager/WebhookManagerService.ts | 88 +- .../services/webhookManager/event-types.ts | 8 +- packages/core/src/testing/effect-vitest.ts | 8 +- .../crypto/PaymentConfigSecretCrypto.test.ts | 100 +- .../utils/crypto/PaymentConfigSecretCrypto.ts | 49 +- .../core/src/utils/crypto/SecretBox.test.ts | 137 +- packages/core/src/utils/crypto/SecretBox.ts | 64 +- .../src/utils/crypto/account-token.test.ts | 48 +- .../core/src/utils/crypto/account-token.ts | 19 +- packages/core/src/utils/crypto/jwt-sign.ts | 62 +- .../src/utils/crypto/standalone-auth-token.ts | 143 +- packages/core/src/utils/deterministic-id.ts | 4 +- packages/core/src/utils/generate-id.ts | 5 +- .../AppStoreExpireParkedNotifications.ts | 6 +- .../AppStoreReconcileOriginalTransaction.ts | 4 +- packages/core/src/workflows/DeliverWebhook.ts | 2 +- packages/core/src/workflows/registry.test.ts | 67 +- .../core/test/_testing/CoreAuthSession.ts | 8 +- .../_testing/CoreIntegrationTestHarness.ts | 37 +- .../core/test/_testing/CoreTestFixture.ts | 6 +- packages/core/test/_testing/CoreTestSeed.ts | 17 +- .../PurchaseIntegrationTestHarness.ts | 29 +- .../_testing/ReactNativePurchaseHarness.ts | 11 +- packages/core/test/_testing/globalSetup.ts | 37 +- .../core/test/_testing/purchaseGlobalSetup.ts | 29 +- .../test/domain/analytics/Analytics.test.ts | 4 +- .../domain/analytics/custom-insights.test.ts | 638 +++--- .../domain/analytics/filter-compiler.test.ts | 448 ++-- .../test/domain/analytics/insights.test.ts | 104 +- .../analytics/resolve-time-range.test.ts | 131 +- .../analyticsIngest/AnalyticsIngest.test.ts | 73 +- packages/core/test/domain/avatar.test.ts | 176 +- .../InternalAnalyticsEvents.test.ts | 139 +- .../test/domain/person/IdentityGraph.test.ts | 50 +- .../core/test/domain/person/Person.test.ts | 33 +- packages/core/test/runtime-context-types.ts | 11 +- .../AnalyticsService.integration.test.ts | 39 +- .../clickhouse-accessor.integration.test.ts | 114 +- .../analytics/series-resolver.test.ts | 501 +++-- ...lyticsIngestDlqService.integration.test.ts | 13 +- ...nalyticsJanitorService.integration.test.ts | 32 +- ...AnalyticsWriterService.integration.test.ts | 210 +- .../AnalyticsWriterService.test.ts | 5 +- .../DlqProducer.integration.test.ts | 121 +- .../EventCaptureService.integration.test.ts | 161 +- .../EventCaptureService.test.ts | 201 +- .../EventProcessorService.integration.test.ts | 205 +- .../EventProcessorService.test.ts | 438 ++-- .../apiKeys/ApiKeyService.integration.test.ts | 28 +- .../test/services/apiKeys/create-hash.test.ts | 160 +- ...ocalUserSessionService.integration.test.ts | 104 +- ...tService.authorization.integration.test.ts | 10 +- .../FeatureFlagService.integration.test.ts | 84 +- .../services/feedback/FeedbackService.test.ts | 12 +- .../fxRates/FxRateService.integration.test.ts | 225 +- .../fxRates/exchange-rate-api-fetcher.test.ts | 548 +++-- .../InternalFeatureFlagService.test.ts | 5 +- ...ficationsAuthorization.integration.test.ts | 10 +- .../services/notifications/jwt-sign.test.ts | 178 +- .../push-delivery-classification.test.ts | 64 +- .../notifications/push-providers.test.ts | 259 ++- .../OrganizationLifecyclePort.test.ts | 11 +- .../OrganizationMembershipSyncPort.test.ts | 35 +- .../OrganizationMembershipWebhookPort.test.ts | 47 +- .../OrganizationService.integration.test.ts | 40 +- ...erConfigurationService.integration.test.ts | 76 +- ...ProviderProductService.integration.test.ts | 66 +- ...ppStorePaymentProvider.integration.test.ts | 227 +- ...PaymentProviderService.integration.test.ts | 74 +- ...ProviderServiceQueries.integration.test.ts | 33 +- ...eReconciliationService.integration.test.ts | 17 +- .../appStore/sdk-context.test.ts | 328 ++- ...glePlayPaymentProvider.integration.test.ts | 117 +- ...PaymentProviderService.integration.test.ts | 71 +- ...yWebhookHandlerService.integration.test.ts | 14 +- .../StripePaymentProvider.integration.test.ts | 79 +- ...PaymentProviderService.integration.test.ts | 16 +- ...eWebhookHandlerService.integration.test.ts | 68 +- .../stripe/stripe-test-support.ts | 73 +- ...wallAssetAuthorization.integration.test.ts | 10 +- .../PaywallDeployService.integration.test.ts | 26 +- ...PaywallLocationService.integration.test.ts | 135 +- .../services/paywallLocations/helpers.test.ts | 44 +- ...WorkspaceAuthorization.integration.test.ts | 12 +- .../PaywallService.integration.test.ts | 59 +- .../PerkGrantService.integration.test.ts | 57 +- .../perks/PerkService.integration.test.ts | 19 +- ...dentityMutationService.integration.test.ts | 100 +- ...ityProjectionPublisher.integration.test.ts | 154 +- .../PersonIdentityService.integration.test.ts | 86 +- .../persons/PersonService.integration.test.ts | 78 +- .../ProductPerkService.integration.test.ts | 38 +- .../ProductService.integration.test.ts | 19 +- .../test/services/products/helpers.test.ts | 25 +- .../ProjectService.integration.test.ts | 32 +- ...aseLedgerWorkerService.integration.test.ts | 41 +- ...chaseProcessingService.integration.test.ts | 201 +- .../revenue-analytics-mapper.test.ts | 13 +- .../PurchaseService.integration.test.ts | 14 +- .../SchemaCacheInvalidationService.test.ts | 61 +- .../schema/SchemaService.integration.test.ts | 178 +- .../core/test/services/schema/helpers.test.ts | 187 +- .../sdk/SdkService.integration.test.ts | 56 +- .../services/sdk/snapshot-builder.test.ts | 79 +- .../UserAuthorization.integration.test.ts | 10 +- .../test/services/users/UserService.test.ts | 300 +-- .../test/services/voidql/compiler.test.ts | 19 +- .../query-compatibility.integration.test.ts | 11 +- .../voidql/query-compatibility.test.ts | 6 +- .../test/services/voidql/substrate.test.ts | 41 +- ...WebhookDeliveryService.integration.test.ts | 153 +- ...WebhookDispatchService.integration.test.ts | 23 +- .../WebhookManagerService.integration.test.ts | 53 +- packages/core/test/utils/generate-id.test.ts | 5 +- packages/core/test/utils/permissions.test.ts | 392 ++-- packages/db/package.json | 1 + packages/db/src/db.ts | 13 +- packages/db/src/migrations.ts | 257 ++- packages/db/src/schema.ts | 238 +- .../db/tests/migrations.integration.test.ts | 164 +- packages/emails/index.tsx | 0 packages/google-play-server-sdk/package.json | 1 + .../src/client/GooglePlayApiClient.ts | 397 ++-- .../google-play-server-sdk/src/client/auth.ts | 71 +- .../tests/import-ban.test.ts | 69 +- .../tests/schemas.test.ts | 980 ++++---- packages/lib/package.json | 3 +- packages/lib/src/constants/currencies.ts | 14 +- packages/lib/src/constants/feedback.ts | 32 +- packages/lib/src/constants/permissions.ts | 10 +- packages/lib/src/constants/products.ts | 8 +- packages/lib/src/constants/purchases.ts | 6 +- .../lib/src/constants/storefront-vat-rates.ts | 2 +- packages/lib/src/constants/subscriptions.ts | 6 +- packages/lib/src/index.ts | 1 + packages/lib/src/lang/index.ts | 65 + packages/mimic-core/src/core/apply.ts | 84 +- packages/mimic-core/src/core/errors.ts | 13 +- packages/mimic-core/src/core/order.ts | 60 +- packages/mimic-core/src/core/types.ts | 34 +- packages/mimic-core/src/fractional/index.ts | 24 +- packages/mimic-core/src/internal/lang.ts | 18 + packages/mimic-core/src/primitives/Array.ts | 6 +- packages/mimic-core/src/primitives/Boolean.ts | 24 +- packages/mimic-core/src/primitives/Lazy.ts | 37 +- packages/mimic-core/src/primitives/Number.ts | 28 +- packages/mimic-core/src/primitives/String.ts | 28 +- packages/mimic-core/src/primitives/Tree.ts | 234 +- .../mimic-core/src/primitives/TreeNode.ts | 29 +- packages/mimic-core/src/primitives/session.ts | 4 +- packages/mimic-core/src/primitives/value.ts | 5 +- packages/mimic-core/src/schema/defaults.ts | 5 +- packages/mimic-core/src/schema/errors.ts | 12 +- packages/mimic-core/src/schema/model.ts | 31 +- .../mimic-core/src/schema/models/array.ts | 26 +- .../mimic-core/src/schema/models/boolean.ts | 18 +- .../mimic-core/src/schema/models/either.ts | 26 +- .../mimic-core/src/schema/models/literal.ts | 71 +- .../mimic-core/src/schema/models/number.ts | 24 +- .../mimic-core/src/schema/models/object.ts | 10 +- .../mimic-core/src/schema/models/union.ts | 13 +- packages/mimic-core/src/schema/parse.ts | 28 +- packages/mimic-core/src/schema/registry.ts | 11 +- packages/mimic-core/src/schema/types.ts | 5 +- packages/mimic-core/src/schema/validate.ts | 5 +- .../mimic-core/tests/schema/array.test.ts | 6 +- packages/mimic-core/tests/schema/helpers.ts | 22 +- .../mimic-core/tests/schema/number.test.ts | 6 +- .../mimic-core/tests/schema/string.test.ts | 6 +- .../tests/spec-fractional-fixtures.test.ts | 18 +- packages/mimic-core/tsdown.config.ts | 10 +- packages/mimic-schema/package.json | 4 +- packages/mimic-schema/src/document.test.ts | 44 +- packages/mimic-schema/src/locales/entry.ts | 23 +- .../mimic-schema/src/locales/locale-tag.ts | 19 +- .../src/locales/migration.test.ts | 67 +- .../mimic-schema/src/locales/resolve.test.ts | 31 +- packages/mimic-schema/src/locales/resolve.ts | 7 +- .../mimic-schema/src/locales/slots.test.ts | 26 +- packages/mimic-schema/src/locales/slots.ts | 147 +- .../src/nodes/code-component-node.ts | 3 +- .../src/nodes/component-node.test.ts | 12 +- .../mimic-schema/src/nodes/component-node.ts | 3 +- .../mimic-schema/src/nodes/library-node.ts | 3 +- .../mimic-schema/src/nodes/node-catalog.ts | 20 +- packages/mimic-schema/src/nodes/path-node.ts | 3 +- packages/mimic-schema/src/nodes/root-node.ts | 3 +- .../mimic-schema/src/nodes/screen-node.ts | 3 +- .../src/nodes/scrollview-node.test.ts | 16 +- .../mimic-schema/src/nodes/scrollview-node.ts | 3 +- packages/mimic-schema/src/nodes/shape-node.ts | 3 +- packages/mimic-schema/src/nodes/text-node.ts | 3 +- packages/mimic-schema/src/nodes/view-node.ts | 3 +- .../src/reconcile/reconcile.test.ts | Bin 14287 -> 15100 bytes .../mimic-schema/src/reconcile/reconcile.ts | 81 +- .../mimic-schema/src/states/states.test.ts | 36 +- packages/mimic-schema/src/utils/svg-parser.ts | 74 +- packages/mimic-server/package.json | 1 + .../src/effect/CollectionHandle.ts | 78 +- .../mimic-server/src/effect/DatabaseHandle.ts | 10 +- packages/mimic-server/src/effect/MimicSDK.ts | 91 +- .../src/effect/RawCollectionHandle.ts | 191 +- packages/mimic-server/src/effect/RpcClient.ts | 48 +- .../src/migrate/migration/analyze.ts | 58 +- .../src/migrate/migration/definition.ts | 18 +- .../src/migrate/migration/reconcile.ts | 71 +- .../mimic-server/src/migrate/migration/run.ts | 14 +- packages/mimic-server/src/promise/MimicSDK.ts | 22 +- packages/mimic-server/src/rpc/errors.ts | 23 +- .../mimic-server/tests/rpc/contracts.test.ts | 2 +- .../mimic-server/tests/rpc/schemas.test.ts | 5 +- .../sdk/effect/raw-collection-submit.test.ts | 127 +- .../mimic/src/zustand-commander/commander.ts | 55 +- .../mimic/src/zustand-commander/context.ts | 2 +- packages/paywall-build/package.json | 3 + .../paywall-build/scripts/generate-libs.ts | 91 +- packages/paywall-build/src/build.test.ts | 732 +++--- packages/paywall-build/src/build.ts | 106 +- packages/paywall-build/src/compile.ts | 108 +- packages/paywall-build/src/extract.ts | 199 +- packages/paywall-build/src/imports.ts | 48 +- packages/paywall-build/src/memory-fs.ts | 14 +- .../paywall-build/src/node-capabilities.ts | 206 +- packages/paywall-build/src/node-fs.ts | 15 +- packages/paywall-build/src/paths.ts | 67 +- .../src/probe-buildwire-serialization.test.ts | 125 +- .../paywall-build/src/static-manifest.test.ts | 131 +- packages/paywall-build/src/static-manifest.ts | 290 ++- packages/paywall-build/src/typecheck.ts | 23 +- packages/paywall-build/src/types.ts | 4 +- packages/paywall-build/src/validate.ts | 23 +- packages/paywall-renderer-preact/package.json | 2 + .../scripts/generate-runtime-bundle.mjs | 96 +- .../src/runtime/hydrate.tsx | 13 +- .../src/runtime/runtime-locale.test.ts | 28 +- .../src/templates/runtime-bundle.generated.ts | 2 +- .../src/vite-plugin.ts | 14 +- .../src/styles/background.test.ts | 6 +- packages/paywall-workspace/src/paths.ts | 12 +- .../src/service-write.test.ts | 170 +- .../paywall-workspace/src/service-write.ts | 81 +- packages/paywall-workspace/src/snapshot.ts | 47 +- packages/paywall-workspace/src/write.test.ts | 39 +- packages/paywall-workspace/src/write.ts | 26 +- packages/platform/src/CronScheduler.ts | 2 +- packages/platform/src/EffectWorkflowRunner.ts | 99 +- packages/platform/src/TestWorkflowRunner.ts | 9 +- packages/platform/src/Workflow.ts | 15 +- packages/platform/src/WorkflowRegistration.ts | 31 +- .../platform/src/conformance/CronScheduler.ts | 106 +- .../platform/src/conformance/DurableEntity.ts | 398 ++-- packages/platform/src/conformance/Queue.ts | 101 +- packages/platform/src/conformance/Workflow.ts | 128 +- packages/rpc/package.json | 3 + packages/rpc/src/experimentTreatmentTypes.ts | 39 +- packages/rpc/src/groups/PaywallRpcsDef.ts | 8 +- packages/rpc/src/groups/VoidQlRpcsDef.ts | 7 +- packages/rpc/src/internalFeatureFlags.ts | 11 +- packages/shared/src/admin.ts | 2 + packages/shared/src/analytics.ts | 2 + packages/shared/src/api-key.ts | 2 + packages/shared/src/app-store.ts | 2 + packages/shared/src/customer.ts | 2 + packages/shared/src/errors.ts | 2 + packages/shared/src/google-play.ts | 2 + packages/shared/src/organization.ts | 2 + .../src/payment-provider-configuration.ts | 2 + .../shared/src/payment-provider-product.ts | 2 + packages/shared/src/paywall.ts | 2 + packages/shared/src/perk-grant.ts | 2 + packages/shared/src/perk.ts | 2 + packages/shared/src/product-perk.ts | 2 + packages/shared/src/product.ts | 2 + packages/shared/src/project.ts | 2 + packages/shared/src/sdk.ts | 2 + packages/shared/src/user.ts | 2 + packages/shared/src/webhook.ts | 2 + packages/shared/vitest.unit.mts | 7 +- .../ui/components/theme-provider-tanstack.tsx | 27 +- packages/ui/components/ui/carousel.tsx | 5 +- packages/ui/components/ui/chart.tsx | 20 +- packages/ui/components/ui/form.tsx | 6 +- packages/ui/components/ui/sidebar.tsx | 5 +- packages/ui/hooks/use-confirmation-dialog.tsx | 11 +- packages/ui/package.json | 1 + pnpm-lock.yaml | 106 + scripts/check-platform-seam.mjs | 139 +- scripts/check-publication-boundary.mjs | 228 +- scripts/check-selfhost-runtime-boundary.mjs | 189 +- scripts/check-test-tiers.mjs | 201 +- scripts/db-generate.mjs | 29 +- scripts/db-migrate-local.mjs | 329 ++- scripts/generate-node-grouped-client.mjs | 211 +- scripts/generate-openapi-clients.mjs | 266 ++- scripts/publish-pr-packages.mjs | 441 ++-- scripts/run-local-integration.mjs | 635 ++++-- scripts/sync-plugins.ts | 89 +- selfhost/platform/package.json | 2 + selfhost/platform/src/ClusterDurableEntity.ts | 39 +- selfhost/platform/src/CronScheduler.ts | 115 +- selfhost/platform/src/EntityAlarmStore.ts | 6 +- selfhost/platform/src/KeyValueStore.ts | 151 +- selfhost/platform/src/Mailer.ts | 42 +- selfhost/platform/src/MemoryDurableEntity.ts | 11 +- selfhost/platform/src/ObjectStore.ts | 51 +- selfhost/platform/src/Queue.ts | 76 +- selfhost/platform/src/Screenshot.ts | 71 +- .../ChromiumScreenshot.integration.test.ts | 151 +- .../tests/MemoryDurableEntity.test.ts | 121 +- .../tests/NodeDurableEntitySession.test.ts | 41 +- .../tests/PgKeyValueStore.integration.test.ts | 277 +-- .../tests/S3ObjectStore.integration.test.ts | 157 +- selfhost/platform/tests/Screenshot.test.ts | 54 +- .../tests/SingleNodePg.integration.test.ts | 128 +- .../tests/SmtpMailer.integration.test.ts | 272 ++- selfhost/platform/tests/cluster.test.ts | 88 +- selfhost/release-smoke.mts | 749 +++--- selfhost/smoke.mts | 431 ++-- vite.config.ts | 93 + 1050 files changed, 44092 insertions(+), 31911 deletions(-) delete mode 100644 apps/cli/test-monorepo-detection.ts delete mode 100644 apps/www/src/features/studio/shell/components/dashboard-sidebar/dashboard-sidebar-provider.tsx delete mode 100644 apps/www/src/features/studio/shell/components/dashboard-sidebar/index.ts delete mode 100644 apps/www/src/features/studio/shell/components/sidebar/project-settings-sidebar.tsx rename examples/react-native-example/{eslint.config.js => eslint.config.mjs} (50%) delete mode 100755 libraries/react-native/.eslintrc.old.js delete mode 100644 libraries/react-native/src/core/errors.ts delete mode 100644 libraries/react-native/src/core/testing/cache-adapter.ts delete mode 100644 libraries/react-native/src/core/testing/client.ts delete mode 100644 libraries/react-native/src/react/hooks/use-fetch.ts delete mode 100644 packages/emails/index.tsx create mode 100644 packages/lib/src/lang/index.ts create mode 100644 packages/mimic-core/src/internal/lang.ts create mode 100644 vite.config.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 40ed75aba..ca46325b8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -31,6 +31,7 @@ "@voidhash/clickhouse-db": "workspace:*", "@voidhash/core": "workspace:*", "@voidhash/db": "workspace:*", + "@voidhash/lib": "workspace:*", "@voidhash/mimic-core": "workspace:*", "@voidhash/mimic-db": "workspace:*", "@voidhash/mimic-schema": "workspace:*", diff --git a/apps/backend/src/DurableEntityAlarms.ts b/apps/backend/src/DurableEntityAlarms.ts index 55160eb9d..c1298c6f5 100644 --- a/apps/backend/src/DurableEntityAlarms.ts +++ b/apps/backend/src/DurableEntityAlarms.ts @@ -2,7 +2,7 @@ import type { DurableEntityAddress, DurableEntityAlarmControlShape, } from "@voidhash/platform/DurableEntity"; -import { Effect } from "effect"; +import { Clock, Effect } from "effect"; /** Handler for one durable-entity alarm type. */ export type DurableEntityAlarmHandler = ( @@ -19,18 +19,12 @@ export const dispatchDurableEntityAlarms = ( handlers: Readonly>, now?: number, ): Effect.Effect => - Effect.suspend(() => { - const dispatchTime = now ?? Date.now(); - return control - .listDueAlarms(dispatchTime, 100) - .pipe( - Effect.flatMap((due) => - Effect.forEach( - due, - ({ address }) => - handlers[address.type]?.(address, dispatchTime) ?? Effect.void, - { discard: true }, - ), - ), - ); + Effect.gen(function* () { + const dispatchTime = now ?? (yield* Clock.currentTimeMillis); + const due = yield* control.listDueAlarms(dispatchTime, 100); + yield* Effect.forEach( + due, + ({ address }) => handlers[address.type]?.(address, dispatchTime) ?? Effect.void, + { discard: true }, + ); }); diff --git a/apps/backend/src/agent/AgentNodeWebSocket.ts b/apps/backend/src/agent/AgentNodeWebSocket.ts index a2e8a01d4..84a4df96b 100644 --- a/apps/backend/src/agent/AgentNodeWebSocket.ts +++ b/apps/backend/src/agent/AgentNodeWebSocket.ts @@ -64,6 +64,20 @@ export type AgentNodeRouteResult = const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; +/** + * Percent-decodes one path segment, yielding `undefined` for malformed input. + * + * `decodeURIComponent` throws on broken escape sequences, so the throw is + * captured by `Effect.try` and run synchronously to keep this parser pure. + */ +const decodeSegment = (segment: string): string | undefined => + Effect.runSync( + Effect.try({ + try: () => decodeURIComponent(segment), + catch: (cause) => cause, + }).pipe(Effect.catch(() => Effect.succeed(undefined))), + ); + /** Parses and validates the self-host agent upgrade target. */ export const parseAgentNodeRoute = ( request: Pick, @@ -75,12 +89,8 @@ export const parseAgentNodeRoute = ( const organizationId = url.searchParams.get("organizationId")?.trim() ?? ""; const projectId = url.searchParams.get("projectId")?.trim() ?? ""; const surface = url.searchParams.get("surface")?.trim() ?? ""; - let sessionId: string; - try { - sessionId = decodeURIComponent(match[1]); - } catch { - return { _tag: "Invalid" }; - } + const sessionId = decodeSegment(match[1]); + if (sessionId === undefined) return { _tag: "Invalid" }; if ( !organizationId || !projectId || @@ -90,16 +100,9 @@ export const parseAgentNodeRoute = ( return { _tag: "Invalid" }; } const paywallId = url.searchParams.get("paywallId")?.trim() || undefined; - return { - _tag: "Route", - route: { - sessionId, - organizationId, - projectId, - surface, - ...(paywallId === undefined ? {} : { paywallId }), - }, - }; + const route: AgentRoute = { sessionId, organizationId, projectId, surface }; + if (paywallId === undefined) return { _tag: "Route", route }; + return { _tag: "Route", route: { ...route, paywallId } }; }; const rejectUpgrade = (socket: Duplex, status: number, reason: string): void => { @@ -107,34 +110,58 @@ const rejectUpgrade = (socket: Duplex, status: number, reason: string): void => socket.destroy(); }; +const headerEntries = (request: IncomingMessage): Array => { + const entries: Array = []; + for (const [name, value] of Object.entries(request.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + entries.push([name, value.join(", ")]); + continue; + } + entries.push([name, value]); + } + return entries; +}; + const headersOf = (request: IncomingMessage): HttpHeaders.Headers => - HttpHeaders.fromInput( - Object.fromEntries( - Object.entries(request.headers).flatMap(([name, value]) => - value === undefined - ? [] - : [[name, Array.isArray(value) ? value.join(", ") : value] as const], - ), - ), - ); + HttpHeaders.fromInput(Object.fromEntries(headerEntries(request))); const frameOf = (data: RawData, isBinary: boolean): string | Uint8Array => { - if (!isBinary) return data.toString(); + // `ws` hands text frames over as a Buffer, an ArrayBuffer or a Buffer[] + // depending on `binaryType`; only the Buffer case decodes correctly on its + // own, so the other two are normalized before being read as text. + if (!isBinary) { + if (data instanceof ArrayBuffer) return Buffer.from(data).toString(); + if (Array.isArray(data)) return Buffer.concat(data).toString(); + return data.toString(); + } if (data instanceof ArrayBuffer) return new Uint8Array(data); if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data)); return new Uint8Array(data); }; +const withOpenaiBaseUrl = ( + model: Model, + provider: string, + openaiBaseUrl: string | undefined, +): Model => { + if (provider === "openai" && openaiBaseUrl !== undefined) { + return { ...model, baseUrl: openaiBaseUrl }; + } + return model; +}; + const configuredModel = ( provider: string, modelId: string, openaiBaseUrl: string | undefined, ): Model => { const model = getCatalogModel(provider, modelId); - if (model === undefined) throw new Error(`Unknown agent model: ${provider}/${modelId}`); - return provider === "openai" && openaiBaseUrl !== undefined - ? { ...model, baseUrl: openaiBaseUrl } - : model; + // Startup misconfiguration: there is no usable server without a known model. + if (model === undefined) { + return Effect.runSync(Effect.die(new Error(`Unknown agent model: ${provider}/${modelId}`))); + } + return withOpenaiBaseUrl(model, provider, openaiBaseUrl); }; const resolveConfiguredModel = ( @@ -143,11 +170,8 @@ const resolveConfiguredModel = ( openaiBaseUrl: string | undefined, ): Model | undefined => { const model = getCatalogModel(provider, modelId); - return model === undefined - ? undefined - : provider === "openai" && openaiBaseUrl !== undefined - ? { ...model, baseUrl: openaiBaseUrl } - : model; + if (model === undefined) return undefined; + return withOpenaiBaseUrl(model, provider, openaiBaseUrl); }; /** Installs authenticated durable Pi sessions on the self-host HTTP server. */ @@ -165,18 +189,19 @@ export const installAgentNodeWebSocketServer = ( config.visionModelId, config.openaiBaseUrl, ); - const contextFor = (data: AgentConnectionData) => - Context.add(services, AuthSession, data.authSession) as Context.Context< - WorkspaceAgentDeps | AgentSessionIndexService | AuthSession - >; + const contextFor = ( + data: AgentConnectionData, + ): Context.Context => + Context.add(services, AuthSession, data.authSession); + const runOptions = (signal: AbortSignal | undefined) => { + if (signal === undefined) return undefined; + return { signal }; + }; const runAgentEffect: EffectRunner< AgentConnectionData, WorkspaceAgentDeps | AgentSessionIndexService | AuthSession > = (data, effect, signal) => - Effect.runPromise( - effect.pipe(Effect.provide(contextFor(data))), - signal === undefined ? undefined : { signal }, - ); + Effect.runPromise(effect.pipe(Effect.provide(contextFor(data))), runOptions(signal)); const factory = makeWorkspaceAgentSessionFactory({ defaultModel, visionModel, @@ -254,11 +279,10 @@ export const installAgentNodeWebSocketServer = ( webSocket.on("message", (data, isBinary) => { run( Effect.promise(() => connected).pipe( - Effect.flatMap((authorized) => - authorized - ? core.handleMessage(connection, frameOf(data, isBinary)) - : Effect.sync(() => webSocket.close(1008, "Session access denied")), - ), + Effect.flatMap((authorized) => { + if (authorized) return core.handleMessage(connection, frameOf(data, isBinary)); + return Effect.sync(() => webSocket.close(1008, "Session access denied")); + }), ), ); }); diff --git a/apps/backend/src/backend/Analytics.ts b/apps/backend/src/backend/Analytics.ts index a12056356..10cfcb1d1 100644 --- a/apps/backend/src/backend/Analytics.ts +++ b/apps/backend/src/backend/Analytics.ts @@ -25,7 +25,7 @@ import { Db } from "@voidhash/db"; import { KeyValueStore } from "@voidhash/platform/KeyValueStore"; import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { QueueDriver } from "@voidhash/platform/Queue"; -import { Context, Effect, Layer } from "effect"; +import { Context, Effect, Layer, Schema } from "effect"; import type { SelfhostRuntimeConfig } from "../config.ts"; import { makeSelfhostPlatformLive } from "./PlatformProfile.ts"; @@ -36,11 +36,27 @@ const analyticsDeadLetterQueueName = "analytics-ingest-dlq"; const minuteBucket = (value: Date): string => value.toISOString().slice(0, 16); const dayBucket = (value: Date): string => value.toISOString().slice(0, 10); +const minuteMillis = 60_000; + +/** + * Milliseconds left until the next UTC minute boundary. UTC minutes are aligned + * to the epoch, so this is exact modular arithmetic over the instant. + */ const millisecondsUntilNextMinute = (value: Date): number => { - const nextMinute = new Date(value); - nextMinute.setUTCSeconds(0, 0); - nextMinute.setUTCMinutes(nextMinute.getUTCMinutes() + 1); - return Math.max(nextMinute.getTime() - value.getTime(), 0); + const remainder = value.getTime() % minuteMillis; + if (remainder === 0) return minuteMillis; + return minuteMillis - remainder; +}; + +/** JSON text of an ingest envelope as stored on the processed record. */ +const encodeEnvelopeJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +/** Best-effort human text for an unknown queue/driver error. */ +const errorCauseText = (error: unknown): string => { + if (typeof error === "object" && error !== null && "cause" in error) { + return String(error.cause); + } + return String(error); }; const makePolicyCounterStoreLive = Layer.effect( @@ -60,25 +76,23 @@ const makePolicyCounterStoreLive = Layer.effect( ), ); return PolicyCounterStore.of({ - checkEventQuota: ({ now, projectId, quota }) => - typeof quota !== "number" || quota < 1 - ? Effect.succeed(true) - : increment(`events:${projectId}:${dayBucket(now)}`, 172_800_000).pipe( - Effect.map((count) => count <= quota), - ), - checkRequestLimit: ({ now, projectId, requestsPerMinute }) => - typeof requestsPerMinute !== "number" || requestsPerMinute < 1 - ? Effect.succeed({ allowed: true }) - : increment(`requests:${projectId}:${minuteBucket(now)}`, 120_000).pipe( - Effect.map((count) => - count <= requestsPerMinute - ? { allowed: true } - : { - allowed: false, - retryAfterMs: millisecondsUntilNextMinute(now), - }, - ), - ), + checkEventQuota: ({ now, projectId, quota }) => { + if (typeof quota !== "number" || quota < 1) return Effect.succeed(true); + return increment(`events:${projectId}:${dayBucket(now)}`, 172_800_000).pipe( + Effect.map((count) => count <= quota), + ); + }, + checkRequestLimit: ({ now, projectId, requestsPerMinute }) => { + if (typeof requestsPerMinute !== "number" || requestsPerMinute < 1) { + return Effect.succeed({ allowed: true }); + } + return increment(`requests:${projectId}:${minuteBucket(now)}`, 120_000).pipe( + Effect.map((count) => { + if (count <= requestsPerMinute) return { allowed: true }; + return { allowed: false, retryAfterMs: millisecondsUntilNextMinute(now) }; + }), + ); + }, }); }), ); @@ -125,10 +139,7 @@ const makeCaptureIngressLive = Layer.effect( Effect.mapError( (error) => new CaptureIngressError({ - cause: - typeof error === "object" && error !== null && "cause" in error - ? String(error.cause) - : String(error), + cause: errorCauseText(error), message: "failed to enqueue captured analytics events", }), ), @@ -186,20 +197,24 @@ export const runSelfhostAnalyticsConsumers = ( ), Layer.provide(database), ); - const writerContext = clickhouse - ? yield* Effect.gen(function* () { - const clickhouseContext = yield* Layer.build(clickhouse); - const client = Context.get( - clickhouseContext, - ClickhouseWebClient.ClickhouseWebClient, - ); - return yield* Layer.build( - AnalyticsWriterService.layerWithClickhouse(client).pipe( - Layer.provide(database), - ), - ); - }) - : yield* Layer.build(AnalyticsWriterService.layer.pipe(Layer.provide(database))); + const buildWriterContext = () => { + if (clickhouse === undefined) { + return Layer.build(AnalyticsWriterService.layer.pipe(Layer.provide(database))); + } + return Effect.gen(function* () { + const clickhouseContext = yield* Layer.build(clickhouse); + const client = Context.get( + clickhouseContext, + ClickhouseWebClient.ClickhouseWebClient, + ); + return yield* Layer.build( + AnalyticsWriterService.layerWithClickhouse(client).pipe( + Layer.provide(database), + ), + ); + }); + }; + const writerContext = yield* buildWriterContext(); const analyticsWriter = Context.get(writerContext, AnalyticsWriterService); const consumeAnalytics = queues.consumeBatch( @@ -214,7 +229,7 @@ export const runSelfhostAnalyticsConsumers = ( capturedEvent: message.envelope, headers: {}, lane: message.lane, - rawValue: JSON.stringify(message.envelope), + rawValue: encodeEnvelopeJson(message.envelope), sourceOffset: message.envelope.captureId, sourcePartition: 0, sourceTopic: message.envelope.routing.targetTopic, diff --git a/apps/backend/src/backend/Backend.ts b/apps/backend/src/backend/Backend.ts index 65f02ab8b..5b4ce15bc 100644 --- a/apps/backend/src/backend/Backend.ts +++ b/apps/backend/src/backend/Backend.ts @@ -19,7 +19,7 @@ import { PaywallAssetConfig } from "@voidhash/core/services/paywallLocations/Pay import { Db } from "@voidhash/db"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; import { SelfhostPlatformRuntimeLive } from "@voidhash/platform-selfhost/PlatformRuntime"; -import { Effect, Layer, Redacted } from "effect"; +import { Layer, Redacted } from "effect"; import type { SelfhostAuthConfig, SelfhostRuntimeConfig } from "../config.ts"; import { makeHttpComponentCompilerLive } from "../compiler/CompilerClient.ts"; diff --git a/apps/backend/src/backend/Background.ts b/apps/backend/src/backend/Background.ts index 786578d67..7922f3b9c 100644 --- a/apps/backend/src/backend/Background.ts +++ b/apps/backend/src/backend/Background.ts @@ -5,14 +5,17 @@ import { backendWorkflows } from "@voidhash/core/workflows/registry"; import { CronJob, CronScheduler } from "@voidhash/platform/CronScheduler"; import type { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import type { WorkflowRunner } from "@voidhash/platform/WorkflowRunner"; -import { Context, Effect, Layer } from "effect"; +import { Config, Context, Effect, Layer } from "effect"; /** Builds the persisted jobs enabled by the current self-host configuration. */ export const makeSelfhostCronJobs = ( clickhouse?: Layer.Layer, ) => Effect.gen(function* () { - const exchangeRateApiKey = process.env.EXCHANGE_RATE_API_KEY?.trim(); + const exchangeRateApiKey = (yield* Config.string("EXCHANGE_RATE_API_KEY").pipe( + Config.withDefault(""), + Effect.orDie, + )).trim(); const jobs: Array> = backendWorkflows.flatMap( (registration) => { if (registration.cron === undefined) return []; diff --git a/apps/backend/src/backend/Clickhouse.ts b/apps/backend/src/backend/Clickhouse.ts index 6d8fca13a..a01788842 100644 --- a/apps/backend/src/backend/Clickhouse.ts +++ b/apps/backend/src/backend/Clickhouse.ts @@ -8,32 +8,38 @@ import { CLICKHOUSE_PERSONS_TABLE, } from "@voidhash/clickhouse-db/analytics/schema"; import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; +import { constant } from "@voidhash/lib/lang"; import { Effect, Layer, Schedule } from "effect"; import { SqlClient } from "effect/unstable/sql"; import type { SelfhostClickhouseConfig } from "../config.ts"; -const tenantTables = [ +const tenantTables = constant([ CLICKHOUSE_EVENTS_TABLE, CLICKHOUSE_PERSONS_TABLE, CLICKHOUSE_PERSON_IDENTITY_TABLE, CLICKHOUSE_PERSON_IDENTITY_OVERRIDES_TABLE, CLICKHOUSE_PERSON_IDENTITY_PENDING_OVERRIDES_V2_TABLE, -] as const; +]); -const queryTables = [ +const queryTables = constant([ CLICKHOUSE_EVENTS_TABLE, CLICKHOUSE_PERSONS_TABLE, CLICKHOUSE_PERSON_IDENTITY_PENDING_OVERRIDES_V2_TABLE, -] as const; +]); const identifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/; -const assertIdentifier = (name: string, value: string): string => { +/** + * Guards a configured ClickHouse identifier before it is interpolated into DDL. + * A bad identifier is a deployment misconfiguration, not a recoverable failure, + * so it is raised as a defect exactly as the previous `throw` was. + */ +const assertIdentifier = (name: string, value: string): Effect.Effect => { if (!identifierPattern.test(value)) { - throw new Error(`${name} must be a ClickHouse identifier`); + return Effect.die(new Error(`${name} must be a ClickHouse identifier`)); } - return value; + return Effect.succeed(value); }; const makeClientLive = (config: SelfhostClickhouseConfig["readWrite"]) => @@ -50,11 +56,20 @@ const provisionSelfhostClickhouseAccess = (config: SelfhostClickhouseConfig) => Effect.gen(function* () { const ch = yield* ClickhouseWebClient.ClickhouseWebClient; const sql = yield* SqlClient.SqlClient; - const database = assertIdentifier("CLICKHOUSE_DATABASE", config.admin.database); - const adminUser = assertIdentifier("CLICKHOUSE_ADMIN_USERNAME", config.admin.username); - const readWriteUser = assertIdentifier("CLICKHOUSE_USERNAME", config.readWrite.username); - const readOnlyUser = assertIdentifier("CLICKHOUSE_RO_USERNAME", config.readOnly.username); - const queryUser = assertIdentifier( + const database = yield* assertIdentifier("CLICKHOUSE_DATABASE", config.admin.database); + const adminUser = yield* assertIdentifier( + "CLICKHOUSE_ADMIN_USERNAME", + config.admin.username, + ); + const readWriteUser = yield* assertIdentifier( + "CLICKHOUSE_USERNAME", + config.readWrite.username, + ); + const readOnlyUser = yield* assertIdentifier( + "CLICKHOUSE_RO_USERNAME", + config.readOnly.username, + ); + const queryUser = yield* assertIdentifier( "CLICKHOUSE_ANALYTICS_QUERY_USERNAME", config.analyticsQuery.username, ); @@ -62,11 +77,11 @@ const provisionSelfhostClickhouseAccess = (config: SelfhostClickhouseConfig) => const readOnlyRole = `${database}_ro_role`; const queryRole = `${database}_query_role`; - for (const [user, password] of [ + for (const [user, password] of constant([ [readWriteUser, config.readWrite.password], [readOnlyUser, config.readOnly.password], [queryUser, config.analyticsQuery.password], - ] as const) { + ])) { yield* ch.asCommand(sql` CREATE USER IF NOT EXISTS ${sql(user)} IDENTIFIED WITH sha256_password BY ${password} `); diff --git a/apps/backend/src/backend/MimicHost.ts b/apps/backend/src/backend/MimicHost.ts index ff23db6bc..03d79b4d3 100644 --- a/apps/backend/src/backend/MimicHost.ts +++ b/apps/backend/src/backend/MimicHost.ts @@ -3,6 +3,9 @@ import { MimicHostError, type MimicHostShape, } from "@voidhash/core/services/paywalls/MimicHost"; +import { generateId } from "@voidhash/core/utils/generate-id"; +import { causeMessage } from "@voidhash/lib/lang"; +import type { Value } from "@voidhash/mimic-core"; import { HostServiceTag, type HostService } from "@voidhash/mimic-db/app/hostService"; import { decodeTransactionEnvelope } from "@voidhash/mimic-db/document/transaction"; import { @@ -12,7 +15,7 @@ import { PaywallDesignerDocument, PresenceSchema, } from "@voidhash/mimic-schema"; -import { Effect, Layer, Semaphore } from "effect"; +import { Clock, DateTime, Effect, Layer, Semaphore } from "effect"; const editTokenTtlSeconds = 300; const agentConnectionLeaseMs = 5 * 60 * 1000; @@ -24,19 +27,36 @@ interface ProvisioningIds { const hostError = (message: string, cause: unknown) => new MimicHostError({ - cause: cause instanceof Error ? cause.message : String(cause), + cause: causeMessage(cause), message, }); -const errorTag = (cause: unknown): string | undefined => - typeof cause === "object" && cause !== null && "_tag" in cause ? String(cause._tag) : undefined; +/** Wraps `cause` unless it already is a {@link MimicHostError}. */ +const toHostError = + (message: string) => + (cause: unknown): MimicHostError => { + if (cause instanceof MimicHostError) return cause; + return hostError(message, cause); + }; + +const registryError = (message: string) => new MimicHostError({ cause: message, message }); + +const errorTag = (cause: unknown): string | undefined => { + if (typeof cause === "object" && cause !== null && "_tag" in cause) return String(cause._tag); + return undefined; +}; const isNotFound = (cause: unknown): boolean => errorTag(cause) === "NotFoundError"; const isConflict = (cause: unknown): boolean => errorTag(cause) === "ConflictError"; +const websocketProtocol = (protocol: string): string => { + if (protocol === "https:") return "wss:"; + return "ws:"; +}; + const connectionUrl = (publicBaseUrl: string, ids: ProvisioningIds, paywallId: string): string => { const base = new URL(publicBaseUrl); - base.protocol = base.protocol === "https:" ? "wss:" : "ws:"; + base.protocol = websocketProtocol(base.protocol); base.pathname = `/ws/v1/databases/${encodeURIComponent( ids.databaseId, )}/collections/${encodeURIComponent(ids.collectionId)}/documents/${encodeURIComponent( @@ -54,9 +74,8 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape const resolveDatabase = Effect.gen(function* () { const listed = yield* host.listDatabases(); const existing = listed.find((database) => database.name === MIMIC_DATABASE_NAME); - return existing - ? existing.id - : yield* Effect.fail(new Error(`Missing registry database ${MIMIC_DATABASE_NAME}`)); + if (existing) return existing.id; + return yield* registryError(`Missing registry database ${MIMIC_DATABASE_NAME}`); }); const resolveCollection = (databaseId: string) => @@ -65,11 +84,10 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape const existing = listed.find( (collection) => collection.name === MIMIC_PAYWALLS_COLLECTION_NAME, ); - return existing - ? existing.id - : yield* Effect.fail( - new Error(`Missing registry collection ${MIMIC_PAYWALLS_COLLECTION_NAME}`), - ); + if (existing) return existing.id; + return yield* registryError( + `Missing registry collection ${MIMIC_PAYWALLS_COLLECTION_NAME}`, + ); }); const provision = provisioningLock.withPermit( @@ -92,29 +110,25 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape Effect.flatMap(({ collectionId }) => host.getDocument(collectionId, paywallId).pipe( Effect.asVoid, - Effect.catch((cause) => - isNotFound(cause) - ? host - .createDocument( - collectionId, - paywallId, - PaywallDesignerDocument.encode(createInitialPaywallDocumentInput()), - ) - .pipe( - Effect.asVoid, - Effect.catch((createCause) => - isConflict(createCause) ? Effect.void : Effect.fail(createCause), - ), - ) - : Effect.fail(cause), - ), + Effect.catch((cause) => { + if (!isNotFound(cause)) return Effect.fail(cause); + return host + .createDocument( + collectionId, + paywallId, + PaywallDesignerDocument.encode(createInitialPaywallDocumentInput()), + ) + .pipe( + Effect.asVoid, + Effect.catch((createCause) => { + if (isConflict(createCause)) return Effect.void; + return Effect.fail(createCause); + }), + ); + }), ), ), - Effect.mapError((cause) => - cause instanceof MimicHostError - ? cause - : hostError(`Failed to ensure paywall document ${paywallId}`, cause), - ), + Effect.mapError(toHostError(`Failed to ensure paywall document ${paywallId}`)), ); const getDocument = (paywallId: string) => @@ -123,8 +137,8 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape Effect.mapError((cause) => hostError(`Failed to read paywall document ${paywallId}`, cause)), ); - const toPaywallDocument = (document: { readonly value: unknown; readonly version: number }) => { - const roots = PaywallDesignerDocument.decode(document.value as never); + const toPaywallDocument = (document: { readonly value: Value; readonly version: number }) => { + const roots = PaywallDesignerDocument.decode(document.value); return { root: roots?.[0], tree: document.value, @@ -139,11 +153,15 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape host .createDocumentAuthToken(ids.collectionId, paywallId, "write", [], editTokenTtlSeconds) .pipe( - Effect.map(({ token }) => ({ - expiresAt: new Date(Date.now() + editTokenTtlSeconds * 1000), - token, - url: connectionUrl(publicBaseUrl, ids, paywallId), - })), + Effect.flatMap(({ token }) => + Effect.map(Clock.currentTimeMillis, (now) => ({ + expiresAt: DateTime.toDateUtc( + DateTime.makeUnsafe(now + editTokenTtlSeconds * 1000), + ), + token, + url: connectionUrl(publicBaseUrl, ids, paywallId), + })), + ), ), ), Effect.mapError((cause) => hostError(`Failed to mint a token for ${paywallId}`, cause)), @@ -162,7 +180,7 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape decodeTransactionEnvelope({ baseVersion: input.baseVersion, commands: input.commands, - id: crypto.randomUUID(), + id: generateId("transaction"), }), catch: (cause) => hostError("Invalid mimic transaction", cause), }).pipe( @@ -172,11 +190,7 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape ), ), Effect.map((result) => ({ accepted: result.accepted, version: result.version })), - Effect.mapError((cause) => - cause instanceof MimicHostError - ? cause - : hostError(`Failed to update paywall document ${paywallId}`, cause), - ), + Effect.mapError(toHostError(`Failed to update paywall document ${paywallId}`)), ), openPaywallConnection: ({ paywallId, connectionId, presence }) => provision.pipe( @@ -240,7 +254,7 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape decodeTransactionEnvelope({ baseVersion: input.baseVersion, commands: input.commands, - id: crypto.randomUUID(), + id: generateId("transaction"), }), catch: (cause) => hostError("Invalid mimic transaction", cause), }).pipe( @@ -256,11 +270,7 @@ const makeMimicHost = (host: HostService, publicBaseUrl: string): MimicHostShape ), ), Effect.map((result) => ({ accepted: result.accepted, version: result.version })), - Effect.mapError((cause) => - cause instanceof MimicHostError - ? cause - : hostError(`Failed to update connected paywall ${paywallId}`, cause), - ), + Effect.mapError(toHostError(`Failed to update connected paywall ${paywallId}`)), ), }; }; diff --git a/apps/backend/src/backend/ObjectStores.ts b/apps/backend/src/backend/ObjectStores.ts index bd4ee914b..27560ee3f 100644 --- a/apps/backend/src/backend/ObjectStores.ts +++ b/apps/backend/src/backend/ObjectStores.ts @@ -14,10 +14,12 @@ import { } from "@voidhash/platform-selfhost/ObjectStore"; import { Effect, Layer, Option } from "effect"; -const objectStoreCause = (cause: unknown): string => - cause instanceof ObjectStoreError - ? `${cause.operation} ${cause.bucketName}/${cause.key}: ${cause.cause}` - : String(cause); +const objectStoreCause = (cause: unknown): string => { + if (cause instanceof ObjectStoreError) { + return `${cause.operation} ${cause.bucketName}/${cause.key}: ${cause.cause}`; + } + return String(cause); +}; const artifactError = (operation: string, cause: unknown) => new PaywallArtifactStoreError({ diff --git a/apps/backend/src/backend/PlatformProfile.ts b/apps/backend/src/backend/PlatformProfile.ts index 4d097e858..730789e7d 100644 --- a/apps/backend/src/backend/PlatformProfile.ts +++ b/apps/backend/src/backend/PlatformProfile.ts @@ -21,6 +21,7 @@ import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; import { ClusterQueueLive } from "@voidhash/platform-selfhost/Queue"; import { SingleNodeClusterLive } from "@voidhash/platform-selfhost/Topology"; import * as ClusterWorkflowRunner from "@voidhash/platform-selfhost/Workflow"; +import { pick } from "@voidhash/lib/lang"; import { Layer, Redacted } from "effect"; import { KeyValueStore as PersistenceKeyValueStore, @@ -69,7 +70,7 @@ export const selfhostPlatformPostgres = (database: DbConfig): PgPlatformConfig = host: database.host, password: Redacted.make(database.password), port: database.port, - ...(database.ssl === undefined ? {} : { ssl: database.ssl }), + ...pick(database.ssl === undefined, {}, { ssl: database.ssl }), username: database.username, }); @@ -80,7 +81,7 @@ const platformLayers = (postgres: PgPlatformConfig): SelfhostPlatformLayers => { password: postgres.password, port: postgres.port, username: postgres.username, - ...(postgres.ssl === undefined ? {} : { ssl: postgres.ssl }), + ...pick(postgres.ssl === undefined, {}, { ssl: postgres.ssl }), }).pipe(Layer.orDie); // Every cluster-backed primitive shares this one topology value so a single diff --git a/apps/backend/src/backend/ProjectSchemaCache.ts b/apps/backend/src/backend/ProjectSchemaCache.ts index 61ef17768..56ff5c536 100644 --- a/apps/backend/src/backend/ProjectSchemaCache.ts +++ b/apps/backend/src/backend/ProjectSchemaCache.ts @@ -1,5 +1,5 @@ import { ProjectSchemaCache } from "@voidhash/core/services"; -import { Effect, Layer } from "effect"; +import { Clock, Effect, Layer } from "effect"; interface CacheEntry { readonly expiresAt: number; @@ -12,10 +12,11 @@ export const MemoryProjectSchemaCacheLive = Layer.sync(ProjectSchemaCache, () => return { getByName: (projectId: string) => ({ get: () => - Effect.sync(() => { + Effect.gen(function* () { const entry = entries.get(projectId); if (!entry) return undefined; - if (entry.expiresAt <= Date.now()) { + const now = yield* Clock.currentTimeMillis; + if (entry.expiresAt <= now) { entries.delete(projectId); return undefined; } @@ -23,8 +24,9 @@ export const MemoryProjectSchemaCacheLive = Layer.sync(ProjectSchemaCache, () => }), invalidate: () => Effect.sync(() => void entries.delete(projectId)), set: (schema: unknown, ttlMs: number) => - Effect.sync(() => { - entries.set(projectId, { expiresAt: Date.now() + ttlMs, schema }); + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + entries.set(projectId, { expiresAt: now + ttlMs, schema }); }), }), }; diff --git a/apps/backend/src/backend/Push.ts b/apps/backend/src/backend/Push.ts index 454b93ed2..e6dd40be7 100644 --- a/apps/backend/src/backend/Push.ts +++ b/apps/backend/src/backend/Push.ts @@ -15,7 +15,7 @@ import { PaymentConfigSecretCrypto } from "@voidhash/core/utils/crypto/PaymentCo import { Db } from "@voidhash/db"; import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { QueueDriver } from "@voidhash/platform/Queue"; -import { Context, Effect, Layer } from "effect"; +import { Config, Context, Effect, Layer } from "effect"; import type { SelfhostRuntimeConfig } from "../config.ts"; @@ -54,12 +54,16 @@ export const SelfhostPushDeliveryDispatchLive = Layer.effect( const makePushDeliveryServiceLive = (config: SelfhostRuntimeConfig) => { const database = Db.layer(config.database); const crypto = PaymentConfigSecretCrypto.layer({ - key: Effect.sync(() => process.env.ENCRYPTION_KEY ?? ""), + key: Config.string("ENCRYPTION_KEY").pipe(Config.withDefault(""), Effect.orDie), }); const providers = Layer.mergeAll( FirebaseCloudMessagingServiceConfigLive, makeApplePushNotificationServiceConfigLive({ - deliveryEnabled: Effect.sync(() => process.env.APNS_DELIVERY_ENABLED === "true"), + deliveryEnabled: Config.string("APNS_DELIVERY_ENABLED").pipe( + Config.withDefault(""), + Effect.map((value) => value === "true"), + Effect.orDie, + ), }), ).pipe(Layer.provide(crypto)); const tokens = NotificationTokenService.layer.pipe( diff --git a/apps/backend/src/backend/Thumbnails.ts b/apps/backend/src/backend/Thumbnails.ts index 132e410ad..380a8776d 100644 --- a/apps/backend/src/backend/Thumbnails.ts +++ b/apps/backend/src/backend/Thumbnails.ts @@ -12,10 +12,7 @@ import { } from "@voidhash/core/services/paywallThumbnails/SnapshotImageRenderer"; import { PublicFileStore } from "@voidhash/core/services/storage/PublicFileStore"; import { ComponentManifestCacheService } from "@voidhash/core/services/paywallWorkspace/ComponentManifestCacheService"; -import type { - PreviewTree, - SnapshotNode, -} from "@voidhash/paywall-renderer-web-core"; +import { causeMessage } from "@voidhash/lib/lang"; import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { QueueDriver } from "@voidhash/platform/Queue"; import { Screenshot } from "@voidhash/platform/Screenshot"; @@ -28,6 +25,32 @@ import { Cause, Effect, Layer } from "effect"; import { mimicDocumentIdleQueueName } from "../mimic/MimicDocumentIdleQueue.ts"; +/** Lazily loads Preact so the React compatibility global can be primed first. */ +const loadPreact = () => import("preact"); + +/** Lazily loads the Preact paywall renderer once the React global is primed. */ +const loadPaywallRenderer = () => import("@voidhash/paywall-renderer-preact"); + +/** + * Structural view of the Preact renderer entry point. `renderPaywallToHtml` is + * declared as a *method* so its parameters are compared bivariantly, which lets + * the deliberately `unknown`-typed {@link SnapshotImageRenderInput} fields (core + * must not depend on the renderer packages) flow through without an assertion. + */ +interface PaywallHtmlRenderer { + renderPaywallToHtml( + this: void, + snapshot: unknown, + options?: { + readonly componentArtifacts?: { + readonly trees?: Record>; + readonly localTrees?: Record>; + }; + readonly hydrate?: boolean; + }, + ): { readonly html: string }; +} + /** Bridges the generic Node screenshot adapter to the thumbnail-domain port. */ export const SelfhostHtmlScreenshotLive = Layer.effect( HtmlScreenshot, @@ -61,17 +84,16 @@ export const SelfhostSnapshotImageRendererLive = Layer.effect( Effect.gen(function* () { const htmlScreenshot = yield* HtmlScreenshot; const publicFileStore = yield* PublicFileStore; - const { renderPaywallToHtml } = yield* Effect.promise(async () => { - const preact = await import("preact"); - const runtimeGlobals = globalThis as unknown as { - React?: typeof preact; - }; - // The production Node entry executes workspace TSX through `tsx`, whose - // classic transform references the React global. Point that compatibility - // hook at Preact before the renderer evaluates any JSX. - runtimeGlobals.React ??= preact; - return import("@voidhash/paywall-renderer-preact"); - }); + const preact = yield* Effect.promise(loadPreact); + // The production Node entry executes workspace TSX through `tsx`, whose + // classic transform references the React global. Point that compatibility + // hook at Preact before the renderer evaluates any JSX. + const existingReact = Reflect.get(globalThis, "React"); + if (existingReact === undefined || existingReact === null) { + Reflect.set(globalThis, "React", preact); + } + const renderer: PaywallHtmlRenderer = yield* Effect.promise(loadPaywallRenderer); + const { renderPaywallToHtml } = renderer; return { render: ({ @@ -85,22 +107,16 @@ export const SelfhostSnapshotImageRendererLive = Layer.effect( Effect.gen(function* () { const html = yield* Effect.try({ try: () => - renderPaywallToHtml(snapshot as SnapshotNode, { + renderPaywallToHtml(snapshot, { componentArtifacts: { - trees: componentTrees as Record< - string, - Record - >, - localTrees: localComponentTrees as Record< - string, - Record - >, + trees: componentTrees, + localTrees: localComponentTrees, }, hydrate: false, }).html, catch: (cause) => new SnapshotImageRenderError({ - cause: cause instanceof Error ? cause.message : String(cause), + cause: causeMessage(cause), message: "rendering the paywall snapshot to HTML failed", }), }); diff --git a/apps/backend/src/compiler/CompilerClient.ts b/apps/backend/src/compiler/CompilerClient.ts index e1d2025cd..c7cd8cb07 100644 --- a/apps/backend/src/compiler/CompilerClient.ts +++ b/apps/backend/src/compiler/CompilerClient.ts @@ -1,5 +1,6 @@ import { ComponentCompiler } from "@voidhash/core/services/paywallWorkspace/ComponentCompiler"; -import { Effect, Layer, Schema } from "effect"; +import { Data, Effect, Layer, Schema } from "effect"; +import { FetchHttpClient, HttpBody, HttpClient } from "effect/unstable/http"; import { CompileCheckResponse, @@ -9,6 +10,20 @@ import { const decodeCheck = Schema.decodeUnknownEffect(CompileCheckResponse); const decodeExtract = Schema.decodeUnknownEffect(CompileExtractResponse); +const encodeCompileBody = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + mode: Schema.Literals(["check", "extract"]), + source: Schema.String, + }), + ), +); + +/** Non-2xx response from the compiler sidecar; degraded into `unavailable`. */ +class CompilerResponseError extends Data.TaggedError("CompilerResponseError")<{ + readonly message: string; +}> {} + const callCompiler = ( baseUrl: string, mode: "check" | "extract", @@ -16,25 +31,25 @@ const callCompiler = ( decode: (input: unknown) => Effect.Effect, unavailable: A, ): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const response = await fetch(`${baseUrl}/compile`, { - body: JSON.stringify({ mode, source }), - headers: { "content-type": "application/json" }, - method: "POST", - signal: AbortSignal.timeout(30_000), + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.post(`${baseUrl}/compile`, { + body: HttpBody.text(encodeCompileBody({ mode, source }), "application/json"), + }); + if (response.status < 200 || response.status >= 300) { + return yield* new CompilerResponseError({ + message: `compiler returned HTTP ${response.status}`, }); - if (!response.ok) throw new Error(`compiler returned HTTP ${response.status}`); - return response.json(); - }, - catch: (cause) => cause, + } + return yield* decode(yield* response.json); }).pipe( - Effect.flatMap(decode), + Effect.timeout("30 seconds"), Effect.catchCause((cause) => Effect.logWarning("Component compiler unavailable", { cause }).pipe( Effect.as(unavailable), ), ), + Effect.provide(FetchHttpClient.layer), ); /** HTTP adapter from the backend compiler port to the isolated Node sidecar. */ diff --git a/apps/backend/src/compiler/CompilerCore.ts b/apps/backend/src/compiler/CompilerCore.ts index e861fbff2..f8fc9cba7 100644 --- a/apps/backend/src/compiler/CompilerCore.ts +++ b/apps/backend/src/compiler/CompilerCore.ts @@ -5,7 +5,8 @@ import { type CompileCheckResult, type CompileExtractResult, } from "@voidhash/core/services/paywallWorkspace/ComponentCompiler"; -import { Effect } from "effect"; +import { causeMessage } from "@voidhash/lib/lang"; +import { Data, Effect } from "effect"; import { createContext, Script } from "node:vm"; const manifestEvaluationTimeoutMs = 500; @@ -24,9 +25,44 @@ interface SandboxSurface { readonly renderComponentToTree: (typeof import("@voidhash/paywalls/sandbox"))["renderComponentToTree"]; } +type ComponentDefinitionInput = Parameters[0]; + +/** + * A broken toolchain: esbuild could not be loaded or the transform itself blew + * up. `thrown` keeps the original value so esbuild's structured diagnostics can + * still be read off it. + */ +class CompilerToolchainError extends Data.TaggedError("CompilerToolchainError")<{ + readonly message: string; + readonly thrown: unknown; +}> {} + +/** A failure raised by the user's component while it is evaluated or rendered. */ +class ComponentEvaluationError extends Data.TaggedError("ComponentEvaluationError")<{ + readonly message: string; +}> {} + +const toToolchainError = (thrown: unknown): CompilerToolchainError => + new CompilerToolchainError({ message: causeMessage(thrown), thrown }); + +const toEvaluationError = (thrown: unknown): ComponentEvaluationError => + new ComponentEvaluationError({ message: causeMessage(thrown) }); + +const isEsbuildFailure = (error: unknown): error is EsbuildFailure => { + if (typeof error !== "object" || error === null) return false; + return "errors" in error; +}; + +const isComponentDefinition = (value: unknown): value is ComponentDefinitionInput => { + if (value === null || value === undefined) return false; + if (typeof value !== "object" && typeof value !== "function") return false; + if (!("render" in value)) return false; + return typeof value.render === "function"; +}; + const toCompileDiagnostics = (error: unknown): ComponentCompileDiagnostic[] => { - if (typeof error !== "object" || error === null || !("errors" in error)) return []; - const errors = (error as EsbuildFailure).errors; + if (!isEsbuildFailure(error)) return []; + const errors = error.errors; if (!Array.isArray(errors) || errors.length === 0) return []; return errors.map((message) => ({ column: message.location?.column, @@ -36,124 +72,184 @@ const toCompileDiagnostics = (error: unknown): ComponentCompileDiagnostic[] => { })); }; -const nodeTransform = async (source: string): Promise => { - const esbuild = await import("esbuild"); - const result = await esbuild.transform(source, { - format: "cjs", - jsx: "automatic", - jsxImportSource: "@voidhash/paywalls", - loader: "tsx", - target: "es2022", +const compileErrorResult = ( + diagnostics: ReadonlyArray, +): CompileExtractResult => ({ diagnostics, phase: "compile", status: "error" }); + +const runtimeErrorResult = (message: string): CompileExtractResult => ({ + diagnostics: [{ message }], + phase: "runtime", + status: "error", +}); + +/** Lazily loads esbuild so the toolchain is only pulled in when a compile runs. */ +const importEsbuildModule = () => import("esbuild"); + +/** Lazily loads the paywall sandbox surface used to evaluate compiled components. */ +const importSandboxModule = () => import("@voidhash/paywalls/sandbox"); + +const loadEsbuild = Effect.tryPromise({ + try: importEsbuildModule, + catch: toToolchainError, +}); + +const loadSandbox = Effect.tryPromise({ + try: importSandboxModule, + catch: toToolchainError, +}); + +const nodeTransform = (source: string): Effect.Effect => + Effect.gen(function* () { + const esbuild = yield* loadEsbuild; + const result = yield* Effect.tryPromise({ + try: () => + esbuild.transform(source, { + format: "cjs", + jsx: "automatic", + jsxImportSource: "@voidhash/paywalls", + loader: "tsx", + target: "es2022", + }), + catch: toToolchainError, + }); + return result.code; }); - return result.code; -}; -const evaluateAndExtractManifest = (compiledCode: string, sandbox: SandboxSurface) => { - const requireShim = (specifier: string): unknown => { +/** + * The vm `require` hook is a synchronous V8 callback, so a missing module has to + * leave as a thrown value. Running an already-failed Effect keeps the tagged + * error model without a bare `throw` statement. + */ +const makeRequireShim = + (sandbox: SandboxSurface) => + (specifier: string): unknown => { const module = sandbox.modules[specifier]; - if (module === undefined) throw new Error(`Cannot find module '${specifier}'`); + if (module === undefined) { + return Effect.runSync( + Effect.fail( + new ComponentEvaluationError({ message: `Cannot find module '${specifier}'` }), + ), + ); + } return module; }; - const moduleObject: { exports: Record } = { exports: {} }; - const context = createContext( - { - exports: moduleObject.exports, - module: moduleObject, - require: requireShim, - }, - { - codeGeneration: { strings: false, wasm: false }, - microtaskMode: "afterEvaluate", - name: "voidhash-component-manifest", + +const evaluateModule = ( + compiledCode: string, + sandbox: SandboxSurface, +): Effect.Effect, ComponentEvaluationError> => + Effect.try({ + try: () => { + const moduleObject: { exports: Record } = { exports: {} }; + const context = createContext( + { + exports: moduleObject.exports, + module: moduleObject, + require: makeRequireShim(sandbox), + }, + { + codeGeneration: { strings: false, wasm: false }, + microtaskMode: "afterEvaluate", + name: "voidhash-component-manifest", + }, + ); + new Script(compiledCode, { filename: "component.cjs" }).runInContext(context, { + timeout: manifestEvaluationTimeoutMs, + }); + return moduleObject.exports; }, - ); - new Script(compiledCode, { filename: "component.cjs" }).runInContext(context, { - timeout: manifestEvaluationTimeoutMs, + catch: toEvaluationError, }); - const definition = moduleObject.exports.default ?? moduleObject.exports.definition; - if ( - definition === undefined || - definition === null || - typeof (definition as { render?: unknown }).render !== "function" - ) { - throw new Error("Component must export a default defineComponent({ ... })"); - } - const typedDefinition = definition as Parameters[0]; - return { - definition: typedDefinition, - manifest: sandbox.describeComponent(typedDefinition).manifest, - }; -}; -const compileAndExtract = async (source: string): Promise => { - let compiledCode: string; - try { - compiledCode = await nodeTransform(source); - } catch (error) { - const diagnostics = toCompileDiagnostics(error); - if (diagnostics.length === 0) throw error; - return { diagnostics, phase: "compile", status: "error" }; - } - - const sandbox = await import("@voidhash/paywalls/sandbox"); - try { - const { definition, manifest } = evaluateAndExtractManifest(compiledCode, sandbox); +const evaluateAndExtractManifest = (compiledCode: string, sandbox: SandboxSurface) => + Effect.gen(function* () { + const moduleExports = yield* evaluateModule(compiledCode, sandbox); + const definition = moduleExports.default ?? moduleExports.definition; + if (!isComponentDefinition(definition)) { + return yield* Effect.fail( + new ComponentEvaluationError({ + message: "Component must export a default defineComponent({ ... })", + }), + ); + } + const described = yield* Effect.try({ + try: () => sandbox.describeComponent(definition), + catch: toEvaluationError, + }); + return { definition, manifest: described.manifest }; + }); + +const renderPreviews = ( + compiledCode: string, + sandbox: SandboxSurface, +): Effect.Effect => + Effect.gen(function* () { + const { definition, manifest } = yield* evaluateAndExtractManifest(compiledCode, sandbox); const previewTrees: Record = {}; - const states = manifest.previewStates.length > 0 ? manifest.previewStates : ["default"]; + let states: ReadonlyArray = ["default"]; + if (manifest.previewStates.length > 0) states = manifest.previewStates; for (const state of states) { const fixture = definition.previews?.[state] ?? {}; - previewTrees[state] = await sandbox.renderComponentToTree(definition, { - state, - props: fixture.props, - hostData: { ...sandbox.defaultHostData(), ...fixture.data }, + previewTrees[state] = yield* Effect.tryPromise({ + try: () => + sandbox.renderComponentToTree(definition, { + state, + props: fixture.props, + hostData: { ...sandbox.defaultHostData(), ...fixture.data }, + }), + catch: toEvaluationError, }); } - return { - manifest, - previewTrees, - status: "ready", - }; - } catch (error) { - return { - diagnostics: [{ message: error instanceof Error ? error.message : String(error) }], - phase: "runtime", - status: "error", - }; - } -}; + const ready: CompileExtractResult = { manifest, previewTrees, status: "ready" }; + return ready; + }); + +const compileAndExtract = ( + source: string, +): Effect.Effect => + Effect.gen(function* () { + const compiled = yield* nodeTransform(source).pipe( + Effect.catch((error) => + Effect.gen(function* () { + const diagnostics = toCompileDiagnostics(error.thrown); + if (diagnostics.length === 0) return yield* Effect.fail(error); + const failure: string | CompileExtractResult = compileErrorResult(diagnostics); + return failure; + }), + ), + ); + if (typeof compiled !== "string") return compiled; + + const sandbox = yield* loadSandbox; + return yield* renderPreviews(compiled, sandbox).pipe( + Effect.catch((error) => Effect.succeed(runtimeErrorResult(error.message))), + ); + }); /** Native compiler used exclusively inside the isolated self-host sidecar. */ /** Builds the self-hosted compiler that validates source and renders preview trees. */ export const makeNodeComponentCompiler = (): ComponentCompilerShape => ({ compileCheck: (source) => - Effect.tryPromise(async (): Promise => { - try { - await nodeTransform(source); - return { status: "ready" }; - } catch (error) { - const diagnostics = toCompileDiagnostics(error); - if (diagnostics.length === 0) throw error; - return { diagnostics, status: "error" }; - } - }).pipe( + nodeTransform(source).pipe( + Effect.map((): CompileCheckResult => ({ status: "ready" })), + Effect.catch((error) => + Effect.gen(function* () { + const diagnostics = toCompileDiagnostics(error.thrown); + if (diagnostics.length === 0) return yield* Effect.fail(error); + const failure: CompileCheckResult = { diagnostics, status: "error" }; + return failure; + }), + ), Effect.mapError( (error) => - new ComponentCompilerError({ - message: `esbuild transform failed: ${ - error instanceof Error ? error.message : String(error) - }`, - }), + new ComponentCompilerError({ message: `esbuild transform failed: ${error.message}` }), ), ), compileAndExtract: (source) => - Effect.tryPromise(() => compileAndExtract(source)).pipe( + compileAndExtract(source).pipe( Effect.mapError( (error) => - new ComponentCompilerError({ - message: `component extraction failed: ${ - error instanceof Error ? error.message : String(error) - }`, - }), + new ComponentCompilerError({ message: `component extraction failed: ${error.message}` }), ), ), }); diff --git a/apps/backend/src/compiler/main.ts b/apps/backend/src/compiler/main.ts index f5c4f59a8..8fccbc451 100644 --- a/apps/backend/src/compiler/main.ts +++ b/apps/backend/src/compiler/main.ts @@ -6,7 +6,8 @@ import { } from "node:http"; import { NodeRuntime } from "@effect/platform-node"; -import { Effect, Schema, Semaphore } from "effect"; +import { causeMessage } from "@voidhash/lib/lang"; +import { Cause, Config, Data, Effect, Schema, Semaphore } from "effect"; import { makeNodeComponentCompiler } from "./CompilerCore.ts"; import { CompilerRequest } from "./CompilerProtocol.ts"; @@ -15,87 +16,107 @@ const maximumBodyBytes = 1_048_576; const compiler = makeNodeComponentCompiler(); const compilerPermits = Semaphore.makeUnsafe(2); const decodeRequest = Schema.decodeUnknownEffect(CompilerRequest); +const decodeJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); +const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +class CompilerBodyError extends Data.TaggedError("CompilerBodyError")<{ + readonly message: string; +}> {} const sendJson = (response: ServerResponse, status: number, body: unknown): void => { response.writeHead(status, { "content-type": "application/json" }); - response.end(JSON.stringify(body)); + response.end(encodeJson(body)); }; -const readBody = (request: IncomingMessage): Promise => - new Promise((resolve, reject) => { +const readBody = (request: IncomingMessage): Effect.Effect => + Effect.callback((resume) => { const chunks: Buffer[] = []; let bytes = 0; request.on("data", (chunk: Buffer) => { bytes += chunk.byteLength; if (bytes > maximumBodyBytes) { - reject(new Error("compiler request exceeds 1 MiB")); + resume( + Effect.fail(new CompilerBodyError({ message: "compiler request exceeds 1 MiB" })), + ); request.destroy(); return; } chunks.push(chunk); }); request.on("end", () => { - try { - resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); - } catch (error) { - reject(error); - } + resume( + decodeJson(Buffer.concat(chunks).toString("utf8")).pipe( + Effect.mapError((cause) => new CompilerBodyError({ message: causeMessage(cause) })), + ), + ); + }); + request.on("error", (error) => { + resume(Effect.fail(new CompilerBodyError({ message: causeMessage(error) }))); }); - request.on("error", reject); }); -const handleRequest = async ( +const compileRequest = (input: typeof CompilerRequest.Type) => { + if (input.mode === "check") return compiler.compileCheck(input.source); + return compiler.compileAndExtract(input.source); +}; + +const handleRequest = ( request: IncomingMessage, response: ServerResponse, -): Promise => { - if (request.method === "GET" && request.url === "/health") { - response.writeHead(200, { "content-type": "text/plain" }).end("OK"); - return; - } - if (request.method !== "POST" || request.url !== "/compile") { - sendJson(response, 404, { error: "Not found" }); - return; - } +): Effect.Effect => + Effect.gen(function* () { + if (request.method === "GET" && request.url === "/health") { + response.writeHead(200, { "content-type": "text/plain" }).end("OK"); + return; + } + if (request.method !== "POST" || request.url !== "/compile") { + sendJson(response, 404, { error: "Not found" }); + return; + } - try { - const input = await readBody(request).then((body) => - Effect.runPromise(decodeRequest(body)), - ); - const result = await Effect.runPromise( - compilerPermits.withPermit( - input.mode === "check" - ? compiler.compileCheck(input.source) - : compiler.compileAndExtract(input.source), - ), - ); + const body = yield* readBody(request); + const input = yield* decodeRequest(body); + const result = yield* compilerPermits.withPermit(compileRequest(input)); sendJson(response, 200, result); - } catch (error) { - sendJson(response, 500, { - error: error instanceof Error ? error.message : String(error), - }); - } -}; - -const port = Number(process.env.COMPILER_PORT ?? "5002"); -const host = process.env.COMPILER_HOST?.trim() || "0.0.0.0"; + }).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + sendJson(response, 500, { error: causeMessage(Cause.squash(cause)) }); + }), + ), + ); NodeRuntime.runMain( Effect.scoped( - Effect.acquireRelease( - Effect.callback((resume) => { - const server = createServer((request, response) => { - void handleRequest(request, response); - }); - server.once("error", (error) => resume(Effect.fail(error))); - server.listen(port, host, () => resume(Effect.succeed(server))); - }), - (server) => - Effect.callback((resume) => { - server.close(() => resume(Effect.void)); + Effect.gen(function* () { + const port = yield* Config.port("COMPILER_PORT").pipe( + Config.withDefault(5002), + Effect.orDie, + ); + const configuredHost = yield* Config.string("COMPILER_HOST").pipe( + Config.withDefault("0.0.0.0"), + Effect.orDie, + ); + const host = configuredHost.trim() || "0.0.0.0"; + + return yield* Effect.acquireRelease( + Effect.callback((resume) => { + const server = createServer((request, response) => { + Effect.runFork(handleRequest(request, response)); + }); + server.once("error", (error) => resume(Effect.fail(error))); + server.listen(port, host, () => resume(Effect.succeed(server))); }), - ).pipe( - Effect.tap(() => Effect.logInfo(`Component compiler listening on ${host}:${port}`)), - Effect.andThen(Effect.never), - ), - ) as never, + (server) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), + ).pipe( + Effect.tap(() => + Effect.logInfo(`Component compiler listening on ${host}:${port}`), + ), + Effect.andThen(Effect.never), + ); + }), + ), ); diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index 5196829bb..f8d9a81d9 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -26,6 +26,17 @@ const optionalBooleanFromEnv = (name: string): boolean | undefined => { throw new Error(`${name} must be true or false`); }; +/** + * Reads an optional boolean override as a spreadable fragment. The key is + * omitted entirely when unset, because {@link DbConfig} consumers distinguish + * "no `ssl` key" (driver default) from an explicit `false`. + */ +const sslOverrideFromEnv = (name: string): { readonly ssl?: boolean } => { + const ssl = optionalBooleanFromEnv(name); + if (ssl === undefined) return {}; + return { ssl }; +}; + export type SelfhostMode = "local-evaluation" | "production"; const readSelfhostMode = (): SelfhostMode => { @@ -36,11 +47,8 @@ const readSelfhostMode = (): SelfhostMode => { const isHttpsUrl = (value: string | undefined): boolean => { if (!value) return false; - try { - return new URL(value).protocol === "https:"; - } catch { - return false; - } + if (!URL.canParse(value)) return false; + return new URL(value).protocol === "https:"; }; /** @@ -192,17 +200,14 @@ export const getSelfhostClickhouseConfig = (): SelfhostClickhouseConfig | undefi }; /** Reads the shared application database connection from environment variables. */ -export const getSelfhostDatabaseConfig = (): DbConfig => { - const ssl = optionalBooleanFromEnv("DATABASE_SSL"); - return { - databaseName: process.env.DATABASE_NAME?.trim() || "voidhash", - host: process.env.DATABASE_HOST?.trim() || "127.0.0.1", - password: process.env.DATABASE_PASSWORD ?? "password", - port: positiveIntegerFromEnv("DATABASE_PORT", 5432), - ...(ssl === undefined ? {} : { ssl }), - username: process.env.DATABASE_USERNAME?.trim() || "voidhash", - }; -}; +export const getSelfhostDatabaseConfig = (): DbConfig => ({ + databaseName: process.env.DATABASE_NAME?.trim() || "voidhash", + host: process.env.DATABASE_HOST?.trim() || "127.0.0.1", + password: process.env.DATABASE_PASSWORD ?? "password", + port: positiveIntegerFromEnv("DATABASE_PORT", 5432), + ...sslOverrideFromEnv("DATABASE_SSL"), + username: process.env.DATABASE_USERNAME?.trim() || "voidhash", +}); /** * Reads the application database connection used by out-of-band tooling that @@ -218,14 +223,13 @@ export const getSelfhostDatabaseConfig = (): DbConfig => { */ export const getSelfhostMigrationDatabaseConfig = (): DbConfig => { const fallback = getSelfhostDatabaseConfig(); - const ssl = optionalBooleanFromEnv("DATABASE_DIRECT_SSL"); return { ...fallback, databaseName: process.env.DATABASE_DIRECT_NAME?.trim() || fallback.databaseName, host: process.env.DATABASE_DIRECT_HOST?.trim() || fallback.host, password: process.env.DATABASE_DIRECT_PASSWORD ?? fallback.password, port: positiveIntegerFromEnv("DATABASE_DIRECT_PORT", fallback.port), - ...(ssl === undefined ? {} : { ssl }), + ...sslOverrideFromEnv("DATABASE_DIRECT_SSL"), username: process.env.DATABASE_DIRECT_USERNAME?.trim() || fallback.username, }; }; @@ -247,23 +251,31 @@ export const getSelfhostMigrationDatabaseConfig = (): DbConfig => { */ export const getSelfhostPlatformDatabaseConfig = ( fallback: DbConfig = getSelfhostDatabaseConfig(), -): DbConfig => { - const ssl = optionalBooleanFromEnv("DATABASE_PLATFORM_SSL"); - return { - ...fallback, - databaseName: process.env.DATABASE_PLATFORM_NAME?.trim() || fallback.databaseName, - host: process.env.DATABASE_PLATFORM_HOST?.trim() || fallback.host, - password: process.env.DATABASE_PLATFORM_PASSWORD ?? fallback.password, - port: positiveIntegerFromEnv("DATABASE_PLATFORM_PORT", fallback.port), - ...(ssl === undefined ? {} : { ssl }), - username: process.env.DATABASE_PLATFORM_USERNAME?.trim() || fallback.username, - }; +): DbConfig => ({ + ...fallback, + databaseName: process.env.DATABASE_PLATFORM_NAME?.trim() || fallback.databaseName, + host: process.env.DATABASE_PLATFORM_HOST?.trim() || fallback.host, + password: process.env.DATABASE_PLATFORM_PASSWORD ?? fallback.password, + port: positiveIntegerFromEnv("DATABASE_PLATFORM_PORT", fallback.port), + ...sslOverrideFromEnv("DATABASE_PLATFORM_SSL"), + username: process.env.DATABASE_PLATFORM_USERNAME?.trim() || fallback.username, +}); + +/** Omits SMTP credentials entirely when the transport is unauthenticated. */ +const smtpCredentials = (): { + readonly username?: string; + readonly password?: Redacted.Redacted; +} => { + const credentials: { username?: string; password?: Redacted.Redacted } = {}; + const username = process.env.SMTP_USERNAME?.trim(); + if (username) credentials.username = username; + const password = process.env.SMTP_PASSWORD; + if (password) credentials.password = Redacted.make(password); + return credentials; }; /** Reads the SMTP transport and default sender configuration. */ export const getSelfhostSmtpConfig = (): SmtpMailerConfig => { - const username = process.env.SMTP_USERNAME?.trim() || undefined; - const password = process.env.SMTP_PASSWORD || undefined; return { defaultFrom: { address: process.env.SMTP_FROM_ADDRESS?.trim() || "noreply@voidhash.local", @@ -275,11 +287,50 @@ export const getSelfhostSmtpConfig = (): SmtpMailerConfig => { secure: optionalBooleanFromEnv("SMTP_SECURE") ?? false, tlsRejectUnauthorized: optionalBooleanFromEnv("SMTP_TLS_REJECT_UNAUTHORIZED") ?? true, verifyOnStart: optionalBooleanFromEnv("SMTP_VERIFY_ON_START") ?? false, - ...(username === undefined ? {} : { username }), - ...(password === undefined ? {} : { password: Redacted.make(password) }), + ...smtpCredentials(), }; }; +/** The provider and model used when the operator pins neither explicitly. */ +const defaultAgentModel = ( + openaiApiKey: string | undefined, +): { readonly provider: string; readonly modelId: string } => { + if (openaiApiKey) return { modelId: "gpt-5.4", provider: "openai" }; + return { modelId: "claude-sonnet-4-6", provider: "anthropic" }; +}; + +/** Omits BYO provider keys that were never configured. */ +const agentApiKeys = ( + openaiApiKey: string | undefined, + anthropicApiKey: string | undefined, +): { + readonly openaiApiKey?: Redacted.Redacted; + readonly anthropicApiKey?: Redacted.Redacted; +} => { + const keys: { + openaiApiKey?: Redacted.Redacted; + anthropicApiKey?: Redacted.Redacted; + } = {}; + if (openaiApiKey !== undefined) keys.openaiApiKey = Redacted.make(openaiApiKey); + if (anthropicApiKey !== undefined) keys.anthropicApiKey = Redacted.make(anthropicApiKey); + return keys; +}; + +/** Omits the OpenAI-compatible base URL unless an override is configured. */ +const agentOpenaiBaseUrl = (): { readonly openaiBaseUrl?: string } => { + const openaiBaseUrl = process.env.OPENAI_BASE_URL?.trim(); + if (!openaiBaseUrl) return {}; + return { openaiBaseUrl }; +}; + +/** Omits the ClickHouse block entirely when analytics is disabled. */ +const optionalClickhouse = ( + clickhouse: SelfhostClickhouseConfig | undefined, +): { readonly clickhouse?: SelfhostClickhouseConfig } => { + if (clickhouse === undefined) return {}; + return { clickhouse }; +}; + /** Reads and validates the complete single-process runtime configuration. */ export const getSelfhostRuntimeConfig = (): SelfhostRuntimeConfig => { validateSelfhostSecurityConfig(); @@ -298,8 +349,7 @@ export const getSelfhostRuntimeConfig = (): SelfhostRuntimeConfig => { const clickhouse = getSelfhostClickhouseConfig(); const openaiApiKey = process.env.OPENAI_API_KEY?.trim(); const anthropicApiKey = process.env.ANTHROPIC_API_KEY?.trim(); - const defaultProvider = openaiApiKey ? "openai" : "anthropic"; - const defaultModelId = openaiApiKey ? "gpt-5.4" : "claude-sonnet-4-6"; + const { modelId: defaultModelId, provider: defaultProvider } = defaultAgentModel(openaiApiKey); return { agent: { @@ -307,11 +357,8 @@ export const getSelfhostRuntimeConfig = (): SelfhostRuntimeConfig => { modelId: process.env.VOIDHASH_AGENT_MODEL_ID?.trim() || defaultModelId, visionProvider: process.env.VOIDHASH_AGENT_VISION_MODEL_PROVIDER?.trim() || defaultProvider, visionModelId: process.env.VOIDHASH_AGENT_VISION_MODEL_ID?.trim() || defaultModelId, - ...(openaiApiKey === undefined ? {} : { openaiApiKey: Redacted.make(openaiApiKey) }), - ...(anthropicApiKey === undefined ? {} : { anthropicApiKey: Redacted.make(anthropicApiKey) }), - ...(process.env.OPENAI_BASE_URL?.trim() - ? { openaiBaseUrl: process.env.OPENAI_BASE_URL.trim() } - : {}), + ...agentApiKeys(openaiApiKey, anthropicApiKey), + ...agentOpenaiBaseUrl(), }, artifactObjectStore: { ...objectStore, @@ -319,7 +366,7 @@ export const getSelfhostRuntimeConfig = (): SelfhostRuntimeConfig => { }, auth: getSelfhostAuthConfig(), database: getSelfhostDatabaseConfig(), - ...(clickhouse === undefined ? {} : { clickhouse }), + ...optionalClickhouse(clickhouse), componentCompilerUrl: process.env.COMPONENT_COMPILER_URL?.trim() || "http://127.0.0.1:5002", host: process.env.HOST?.trim() || "0.0.0.0", mailer: getSelfhostSmtpConfig(), diff --git a/apps/backend/src/migrate.ts b/apps/backend/src/migrate.ts index 0ac81c2fe..45e6fac50 100644 --- a/apps/backend/src/migrate.ts +++ b/apps/backend/src/migrate.ts @@ -3,4 +3,4 @@ import { Effect } from "effect"; import { runSelfhostMigrations } from "./migrations.ts"; -NodeRuntime.runMain(Effect.scoped(runSelfhostMigrations()) as never); +NodeRuntime.runMain(Effect.scoped(runSelfhostMigrations())); diff --git a/apps/backend/src/mimic/MimicNodeWebSocket.ts b/apps/backend/src/mimic/MimicNodeWebSocket.ts index ba8843dc1..fbc811d4e 100644 --- a/apps/backend/src/mimic/MimicNodeWebSocket.ts +++ b/apps/backend/src/mimic/MimicNodeWebSocket.ts @@ -1,6 +1,8 @@ import type { IncomingMessage, Server } from "node:http"; import type { Duplex } from "node:stream"; +import { createIdGenerator } from "@voidhash/core/utils/generate-id"; +import { causeMessage, constant } from "@voidhash/lib/lang"; import type { HostService } from "@voidhash/mimic-db/app/hostService"; import { AUTH_DEADLINE_MS, @@ -23,7 +25,7 @@ import { makeDurableEntityAddress, } from "@voidhash/platform/DurableEntity"; import { makeNodeDurableEntitySession } from "@voidhash/platform-selfhost/NodeDurableEntitySession"; -import { Duration, Effect, Fiber, Semaphore } from "effect"; +import { Clock, Duration, Effect, Fiber, Semaphore } from "effect"; import WebSocket, { WebSocketServer, type RawData } from "ws"; import { @@ -58,6 +60,17 @@ export interface MimicNodeIdleNotificationOptions { const mimicDocumentEntityType = "mimic-document"; +/** Ephemeral per-socket connection ids; opaque outside this adapter. */ +const generateConnectionId = createIdGenerator({ connection: "conn" }); + +/** Reads wall-clock millis through the ambient `Clock` from a sync callback. */ +const nowMillis = (): number => Effect.runSync(Clock.currentTimeMillis); + +const numberOrUndefined = (value: unknown): number | undefined => { + if (typeof value === "number") return value; + return undefined; +}; + const documentKey = (collectionId: string, documentId: string): string => `${collectionId}\u0000${documentId}`; @@ -87,13 +100,10 @@ const makeNodeIdleNotifier = ( collectionId, debounceMs: options.debounceMs, documentId, - now: Date.now, + now: nowMillis, publish: options.publish, storage: { - get: (key) => - entity.keyValue - .get(key) - .pipe(Effect.map((value) => (typeof value === "number" ? value : undefined))), + get: (key) => entity.keyValue.get(key).pipe(Effect.map(numberOrUndefined)), put: (key, value) => entity.keyValue.put(key, value), setAlarm: entity.alarm.set, }, @@ -150,7 +160,14 @@ const parseDocumentAddress = ( }; const toFrame = (data: RawData, isBinary: boolean): string | Uint8Array => { - if (!isBinary) return data.toString(); + // `ws` hands text frames over as a Buffer, an ArrayBuffer or a Buffer[] + // depending on `binaryType`; only the Buffer case decodes correctly on its + // own, so the other two are normalized before being read as text. + if (!isBinary) { + if (data instanceof ArrayBuffer) return Buffer.from(data).toString(); + if (Array.isArray(data)) return Buffer.concat(data).toString(); + return data.toString(); + } if (data instanceof ArrayBuffer) return new Uint8Array(data); if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data)); return new Uint8Array(data); @@ -158,8 +175,7 @@ const toFrame = (data: RawData, isBinary: boolean): string | Uint8Array => { // HostService's legacy signatures retain `R = any`; the fully-built entry // layer has already discharged those requirements at this adapter boundary. -const withoutRequirements = (effect: Effect.Effect): Effect.Effect => - effect as Effect.Effect; +const withoutRequirements = (effect: Effect.Effect): Effect.Effect => effect; const run = (effect: Effect.Effect): void => { Effect.runFork( @@ -234,8 +250,8 @@ export const installMimicNodeWebSocketServer = ( Effect.runSync(socket.entitySession.setAttachment(attachment)); }, send: (socket, message) => - Effect.sync(() => void socket.webSocket.send(encodeServerMessage(message))), - close: (socket, code, reason) => Effect.sync(() => void socket.webSocket.close(code, reason)), + Effect.sync(() => socket.webSocket.send(encodeServerMessage(message))), + close: (socket, code, reason) => Effect.sync(() => socket.webSocket.close(code, reason)), authenticate: (token, attachment) => withoutRequirements( host.authenticateDocumentToken( @@ -247,18 +263,16 @@ export const installMimicNodeWebSocketServer = ( ), loadDocument: () => withoutRequirements(host.getDocument(collectionId, documentId)).pipe( - Effect.mapError((error) => ({ - message: error instanceof Error ? error.message : String(error), - })), + Effect.mapError((error) => ({ message: causeMessage(error) })), ), submitTransaction: (transaction) => withoutRequirements(host.submitTransaction(collectionId, documentId, transaction)).pipe( Effect.catch((error) => Effect.succeed({ - accepted: false as const, + accepted: constant(false), version: 0, transactionId: transaction.id, - reason: error instanceof Error ? error.message : String(error), + reason: causeMessage(error), }), ), ), @@ -284,11 +298,11 @@ export const installMimicNodeWebSocketServer = ( webSockets.handleUpgrade(request, socket, head, (webSocket) => { const runtime = runtimeFor(address.collectionId, address.documentId); const attachment: SessionAttachment = { - connectionId: crypto.randomUUID(), + connectionId: generateConnectionId("connection"), collectionId: address.collectionId, documentId: address.documentId, origin: request.headers.origin ?? null, - connectedAt: Date.now(), + connectedAt: nowMillis(), authenticated: false, }; const entitySession = makeNodeDurableEntitySession( diff --git a/apps/backend/src/mimic/PgControlStore.ts b/apps/backend/src/mimic/PgControlStore.ts index 4c1efcf24..01eca2929 100644 --- a/apps/backend/src/mimic/PgControlStore.ts +++ b/apps/backend/src/mimic/PgControlStore.ts @@ -11,7 +11,7 @@ import type { } from "@voidhash/mimic-db/core/store"; import { ControlStore } from "@voidhash/mimic-db/core/store"; import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Predicate, Schema } from "effect"; import { SqlClient } from "effect/unstable/sql"; interface ControlState { @@ -28,6 +28,8 @@ interface ControlStateRow { readonly state: unknown; } +const encodeStateJson = Schema.encodeSync(Schema.UnknownFromJsonString); + const emptyState = (): ControlState => ({ databases: [], collections: [], @@ -38,23 +40,36 @@ const emptyState = (): ControlState => ({ documents: [], }); +/** + * Reads one array-valued field off the persisted control-state blob. + * + * `Array.isArray` narrows `unknown` to `Array`, which lets the caller name + * the row type without an assertion; anything else degrades to an empty list. + */ +const readRows = (state: { readonly [key: PropertyKey]: unknown }, key: string): A[] => { + const rows = state[key]; + if (Array.isArray(rows)) return rows; + return []; +}; + +const migrationVersionOf = (value: number | null | undefined): number | null => { + if (typeof value === "number") return value; + return null; +}; + const decodeState = (value: unknown): ControlState => { - if (typeof value !== "object" || value === null) return emptyState(); - const state = value as Partial; + if (!Predicate.isObject(value)) return emptyState(); return { - databases: Array.isArray(state.databases) ? state.databases : [], - collections: Array.isArray(state.collections) - ? state.collections.map((collection) => ({ - ...collection, - migrationVersion: - typeof collection.migrationVersion === "number" ? collection.migrationVersion : null, - })) - : [], - schemaVersions: Array.isArray(state.schemaVersions) ? state.schemaVersions : [], - users: Array.isArray(state.users) ? state.users : [], - grants: Array.isArray(state.grants) ? state.grants : [], - tokens: Array.isArray(state.tokens) ? state.tokens : [], - documents: Array.isArray(state.documents) ? state.documents : [], + databases: readRows(value, "databases"), + collections: readRows(value, "collections").map((collection) => ({ + ...collection, + migrationVersion: migrationVersionOf(collection.migrationVersion), + })), + schemaVersions: readRows(value, "schemaVersions"), + users: readRows(value, "users"), + grants: readRows(value, "grants"), + tokens: readRows(value, "tokens"), + documents: readRows(value, "documents"), }; }; @@ -87,12 +102,16 @@ export const makePgControlStore = (sql: SqlClient.SqlClient): ControlStoreApi => const load = sql` SELECT state_json AS "state" FROM mimic_control_state WHERE id = 'default' `.pipe( - Effect.map((rows) => (rows[0] ? decodeState(rows[0].state) : emptyState())), + Effect.map((rows) => { + const row = rows[0]; + if (!row) return emptyState(); + return decodeState(row.state); + }), Effect.orDie, ); const save = (state: ControlState) => { - const json = JSON.stringify(state); + const json = encodeStateJson(state); return sql` INSERT INTO mimic_control_state (id, state_json) VALUES ('default', ${json}::jsonb) diff --git a/apps/backend/src/mimic/main.ts b/apps/backend/src/mimic/main.ts index 19792c84b..96305398f 100644 --- a/apps/backend/src/mimic/main.ts +++ b/apps/backend/src/mimic/main.ts @@ -8,7 +8,7 @@ import { DurableEntityAlarmControl, DurableEntityHost, } from "@voidhash/platform/DurableEntity"; -import { Context, Effect, Layer } from "effect"; +import { Config, Context, Effect, Layer } from "effect"; import { HttpRouter } from "effect/unstable/http"; import { makeSelfhostPlatformLayers } from "../backend/PlatformProfile.ts"; @@ -18,7 +18,6 @@ import { makeSelfhostMimicDocumentIdlePublisher } from "./MimicDocumentIdleQueue import { makeMimicNodeHostLive } from "./MimicNode.ts"; import { installMimicNodeWebSocketServer } from "./MimicNodeWebSocket.ts"; -const port = Number(process.env.PORT ?? "5001"); const config = getMimicNodeConfig(); // Idle-document notifications are produced here and consumed by the backend, so // this process has to publish onto the same queue the backend installs there. @@ -32,6 +31,7 @@ const hostLayer = makeMimicNodeHostLive(config, platform.durableEntities); NodeRuntime.runMain( Effect.scoped( Effect.gen(function* () { + const port = yield* Config.port("PORT").pipe(Config.withDefault(5001), Effect.orDie); const hostContext = yield* Layer.build(hostLayer); const host = Context.get(hostContext, HostServiceTag); const entities = Context.get(hostContext, DurableEntityHost); @@ -80,5 +80,8 @@ NodeRuntime.runMain( yield* Effect.logInfo(`Listening on http://0.0.0.0:${port}`); yield* Effect.never; }), + // `HttpRouter.toHttpEffect` leaks `HttpServerRequest` into the program's + // requirements even though `makeHandler` supplies it per request; the + // assertion is the upstream typing escape hatch. ) as never, ); diff --git a/apps/backend/src/release-smoke.ts b/apps/backend/src/release-smoke.ts index 1fa6368c4..40c3de647 100644 --- a/apps/backend/src/release-smoke.ts +++ b/apps/backend/src/release-smoke.ts @@ -7,7 +7,7 @@ import { PaywallReleaseService, } from "@voidhash/core/services"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; -import { Context, Effect, Layer } from "effect"; +import { Config, Console, Context, DateTime, Effect, Layer, Schema } from "effect"; import { makeBackendInfrastructureLive, @@ -20,14 +20,22 @@ import { getMimicNodeConfig } from "./mimic/config.ts"; const resultPrefix = "SELFHOST_RELEASE_RESULT "; -const requiredEnv = (name: string): string => { - const value = process.env[name]?.trim(); - if (!value) throw new Error(`${name} is required`); - return value; -}; +/** JSON text of the smoke result line consumed by the release pipeline. */ +const encodeResultJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +/** + * Reads a required smoke-run environment variable. A missing value is a harness + * misconfiguration, so it is raised as a defect exactly as the previous `throw`. + */ +const requiredEnv = (name: string): Effect.Effect => + Effect.gen(function* () { + const raw = yield* Config.string(name).pipe(Config.withDefault(""), Effect.orDie); + const value = raw.trim(); + if (!value) return yield* Effect.die(new Error(`${name} is required`)); + return value; + }); -const makeSession = (projectId: string, userId: string): AnyAuthSession => { - const now = new Date(); +const makeSession = (projectId: string, userId: string, now: Date): AnyAuthSession => { return { cookie: null, method: "user", @@ -61,9 +69,10 @@ const makeSession = (projectId: string, userId: string): AnyAuthSession => { NodeRuntime.runMain( Effect.scoped( Effect.gen(function* () { - const paywallId = requiredEnv("SELFHOST_RELEASE_PAYWALL_ID"); - const projectId = requiredEnv("SELFHOST_RELEASE_PROJECT_ID"); - const userId = requiredEnv("SELFHOST_RELEASE_USER_ID"); + const paywallId = yield* requiredEnv("SELFHOST_RELEASE_PAYWALL_ID"); + const projectId = yield* requiredEnv("SELFHOST_RELEASE_PROJECT_ID"); + const userId = yield* requiredEnv("SELFHOST_RELEASE_USER_ID"); + const now = yield* DateTime.nowAsDate; const config = getSelfhostRuntimeConfig(); const hostContext = yield* Layer.build( makeMimicNodeHostLive( @@ -99,12 +108,10 @@ NodeRuntime.runMain( return { draft, published }; }).pipe( Effect.provide(releaseLayer), - Effect.provideService(AuthSession, makeSession(projectId, userId)), + Effect.provideService(AuthSession, makeSession(projectId, userId, now)), ); - yield* Effect.sync(() => { - process.stdout.write(`${resultPrefix}${JSON.stringify(result)}\n`); - }); + yield* Console.log(`${resultPrefix}${encodeResultJson(result)}`); }), - ) as never, + ), ); diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index ad6cd70af..35ee69675 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -23,7 +23,8 @@ import { getConfig as getMimicConfig } from "@voidhash/mimic-db/config"; import { makeRoutesLive } from "@voidhash/mimic-db/http/rpc-app"; import { DurableEntityAlarmControl, DurableEntityHost } from "@voidhash/platform/DurableEntity"; import { SmtpMailerLive } from "@voidhash/platform-selfhost/Mailer"; -import { Context, Effect, Layer } from "effect"; +import { causeMessage } from "@voidhash/lib/lang"; +import { Config, Context, Data, Effect, Layer, Option } from "effect"; import { HttpRouter } from "effect/unstable/http"; import { HttpApiBuilder } from "effect/unstable/httpapi"; import type * as Rpc from "effect/unstable/rpc/Rpc"; @@ -64,6 +65,52 @@ const isCaptureRequest = (url: string | undefined): boolean => { return pathname === "/i" || pathname.startsWith("/i/"); }; +/** Boot-time misconfiguration of the self-host process; never crosses a wire. */ +class SelfhostServerBootError extends Data.TaggedError("SelfhostServerBootError")<{ + readonly message: string; +}> {} + +/** Reads an optional environment variable, mirroring `process.env.X`. */ +const optionalEnv = (name: string): Effect.Effect => + Config.string(name).pipe(Config.option, Effect.map(Option.getOrUndefined), Effect.orDie); + +/** Reads an optional environment variable, trimmed, mirroring `process.env.X?.trim()`. */ +const optionalTrimmedEnv = (name: string): Effect.Effect => + optionalEnv(name).pipe(Effect.map((value) => value?.trim())); + +const makeClickhouseLayers = (config: SelfhostRuntimeConfig) => { + if (!config.clickhouse) return undefined; + return makeSelfhostClickhouseLayers(config.clickhouse); +}; + +const makeChromiumConfig = ( + executablePath: string | undefined, + disableSandbox: boolean, +): { readonly disableSandbox: boolean; readonly executablePath: string } | undefined => { + if (!executablePath) return undefined; + return { disableSandbox, executablePath }; +}; + +const makeSnapshotImageRenderer = ( + chromiumConfig: { readonly disableSandbox: boolean; readonly executablePath: string } | undefined, +) => { + if (chromiumConfig === undefined) return undefined; + return makeSelfhostSnapshotImageRendererLive(chromiumConfig); +}; + +/** Loads the WWW handler when both of its environment variables are configured. */ +const loadWwwHandler = (serverEntry: string | undefined, clientDirectory: string | undefined) => + Effect.gen(function* () { + if (!serverEntry || !clientDirectory) return undefined; + return yield* Effect.tryPromise({ + try: () => loadWwwRequestHandler(serverEntry, clientDirectory), + catch: (cause) => + new SelfhostServerBootError({ + message: `Failed to load the WWW server bundle: ${causeMessage(cause)}`, + }), + }); + }); + /** * The runtime values a composition root can only obtain from inside the server * boot sequence, handed to the option factories that need them. @@ -142,24 +189,19 @@ export const runSelfhostServer = < yield* Effect.logInfo( `Identity provider: standalone (root user ${config.auth.rootUsername})`, ); - const clickhouse = config.clickhouse - ? makeSelfhostClickhouseLayers(config.clickhouse) - : undefined; - const chromiumExecutablePath = process.env.CHROMIUM_EXECUTABLE_PATH?.trim(); - const chromiumConfig = chromiumExecutablePath - ? { - disableSandbox: process.env.CHROMIUM_DISABLE_SANDBOX === "true", - executablePath: chromiumExecutablePath, - } - : undefined; + const clickhouse = makeClickhouseLayers(config); + const chromiumExecutablePath = yield* optionalTrimmedEnv("CHROMIUM_EXECUTABLE_PATH"); + const chromiumDisableSandbox = yield* optionalEnv("CHROMIUM_DISABLE_SANDBOX"); + const chromiumConfig = makeChromiumConfig( + chromiumExecutablePath, + chromiumDisableSandbox === "true", + ); const infrastructure = Layer.mergeAll( makeBackendInfrastructureLive( config, authLayers.identity, clickhouse?.readOnly, - chromiumConfig === undefined - ? undefined - : makeSelfhostSnapshotImageRendererLive(chromiumConfig), + makeSnapshotImageRenderer(chromiumConfig), ), options.identityDirectory ?? Layer.empty, ).pipe(Layer.provide(hostLayer)); @@ -192,7 +234,7 @@ export const runSelfhostServer = < features: options.features, infrastructure, pushDeliveryDispatch, - ...(options.mcpOAuth === undefined ? {} : { mcpOAuth: options.mcpOAuth }), + mcpOAuth: options.mcpOAuth, }), infrastructure, ), @@ -242,10 +284,10 @@ export const runSelfhostServer = < features: options.features, rpcExtension, infrastructure, - ...(clickhouse === undefined ? {} : { analyticsQueryClient: clickhouse.analyticsQuery }), + analyticsQueryClient: clickhouse?.analyticsQuery, pushDeliveryDispatch, - ...(options.routeExtension === undefined ? {} : { routeExtension: options.routeExtension }), - ...(options.mcpOAuth === undefined ? {} : { mcpOAuth: options.mcpOAuth }), + routeExtension: options.routeExtension, + mcpOAuth: options.mcpOAuth, }).pipe(Effect.provide(runtimeContext)); const mimicEffect = yield* makeRoutesLive(hostLayer).pipe( Layer.provide(NodeHttpServer.layerHttpServices), @@ -271,20 +313,14 @@ export const runSelfhostServer = < captureEffect.pipe(Effect.provide(runtimeContext)), { scope }, ); - const wwwServerEntry = process.env.WWW_SERVER_ENTRY?.trim(); - const wwwClientDirectory = process.env.WWW_CLIENT_DIRECTORY?.trim(); + const wwwServerEntry = yield* optionalTrimmedEnv("WWW_SERVER_ENTRY"); + const wwwClientDirectory = yield* optionalTrimmedEnv("WWW_CLIENT_DIRECTORY"); if ((wwwServerEntry === undefined) !== (wwwClientDirectory === undefined)) { - return yield* Effect.fail( - new Error("WWW_SERVER_ENTRY and WWW_CLIENT_DIRECTORY must be configured together"), - ); + return yield* new SelfhostServerBootError({ + message: "WWW_SERVER_ENTRY and WWW_CLIENT_DIRECTORY must be configured together", + }); } - const wwwHandler = - wwwServerEntry && wwwClientDirectory - ? yield* Effect.tryPromise({ - try: () => loadWwwRequestHandler(wwwServerEntry, wwwClientDirectory), - catch: (cause) => new Error("Failed to load the WWW server bundle", { cause }), - }) - : undefined; + const wwwHandler = yield* loadWwwHandler(wwwServerEntry, wwwClientDirectory); const server = createServer((request, response) => { if (isMimicRequest(request.url)) { mimicHandler(request, response); @@ -295,8 +331,8 @@ export const runSelfhostServer = < return; } if (wwwHandler !== undefined && isWwwRequest(request.url)) { - wwwHandler(request, response).catch((error) => { - console.error("WWW request failed", error); + wwwHandler(request, response).catch((error: unknown) => { + Effect.runFork(Effect.logError(`WWW request failed: ${causeMessage(error)}`)); if (!response.headersSent) { response.statusCode = 500; } diff --git a/apps/backend/tests/AgentNodeWebSocket.integration.test.ts b/apps/backend/tests/AgentNodeWebSocket.integration.test.ts index 6ae5feb6d..6b0195b7f 100644 --- a/apps/backend/tests/AgentNodeWebSocket.integration.test.ts +++ b/apps/backend/tests/AgentNodeWebSocket.integration.test.ts @@ -8,52 +8,83 @@ import { PaywallWorkspaceService, } from "@voidhash/core/services"; import { Db } from "@voidhash/db"; +import { causeMessage, constant } from "@voidhash/lib/lang"; import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; -import { Context, Effect, Redacted } from "effect"; +import { Context, Data, DateTime, Effect, Latch, Redacted, Schema } from "effect"; import { WebSocket } from "ws"; -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { installAgentNodeWebSocketServer } from "../src/agent/AgentNodeWebSocket.ts"; -const servers: Server[] = []; +class AgentNodeTestError extends Data.TaggedError("AgentNodeTestError")<{ + readonly message: string; +}> {} -afterEach(async () => { - await Promise.all( - servers.splice(0).map( - (server) => - new Promise((resolve) => { - server.close(() => resolve()); - }), - ), +const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString); +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); + +/** Decodes a server frame, keeping the loose shape the assertions below read. */ +const decodeFrame = (raw: string): Record => { + const frame: any = decodeJson(raw); + return frame; +}; + +/** + * Builds a partial service stub. Members that are not listed read as + * `undefined`, exactly like the object literals this replaces, but the value + * types as the full service so no call site needs an assertion. + */ +const serviceStub = (members: object): A => { + const stub: any = { ...members }; + return stub; +}; + +/** + * Listens on an ephemeral loopback port and reports it, closing the server when + * the surrounding scope ends. + */ +const listen = (server: Server) => + Effect.acquireRelease( + Effect.callback((resume) => { + const onError = (error: Error) => + resume(Effect.fail(new AgentNodeTestError({ message: causeMessage(error) }))); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + const address = server.address(); + if (address === null || typeof address === "string") { + resume( + Effect.fail( + new AgentNodeTestError({ message: "HTTP server did not expose a TCP port" }), + ), + ); + return; + } + resume(Effect.succeed(address.port)); + }); + }), + () => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), ); -}); -const listen = (server: Server): Promise => - new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.off("error", reject); - const address = server.address(); - if (address === null || typeof address === "string") { - reject(new Error("HTTP server did not expose a TCP port")); - return; - } - servers.push(server); - resolve(address.port); - }); +const waitFor = (predicate: () => boolean) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 150; attempt += 1) { + if (predicate()) return; + yield* Effect.sleep("20 millis"); + } + return yield* Effect.fail( + new AgentNodeTestError({ message: "Timed out waiting for the Node agent WebSocket" }), + ); }); -const waitFor = async (predicate: () => boolean): Promise => { - for (let attempt = 0; attempt < 150; attempt += 1) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error("Timed out waiting for the Node agent WebSocket"); -}; +const epoch = DateTime.toDateUtc(DateTime.makeUnsafe(0)); const authSession = { cookie: null, - method: "user" as const, + method: constant("user"), name: "Probe user", person: null, organizations: [ @@ -78,8 +109,8 @@ const authSession = { ], user: { id: "user_1", - createdAt: new Date(0), - updatedAt: new Date(0), + createdAt: epoch, + updatedAt: epoch, email: "user@example.com", emailVerified: true, image: null, @@ -100,191 +131,214 @@ const identity = { }; const makeServices = () => { - let context = Context.empty() as Context.Context; - context = Context.add(context, Db, {} as never); - context = Context.add(context, LocalUserSessionService, { - resolveLocalUser: () => Effect.succeed(authSession.user), - loadUserAccess: () => - Effect.succeed({ - organizations: authSession.organizations, - projects: authSession.projects, - }), - toUserSession: () => authSession, - } as unknown as LocalUserSessionService["Service"]); - context = Context.add(context, IdentityProvider, { - cookieName: "voidhash-session", - authenticateSessionCookie: () => Effect.succeed(null), - resolveIdentity: () => Effect.succeed(identity), - resolveIdentityById: () => Effect.succeed(identity), - linkExternalId: () => Effect.void, - } as IdentityProvider["Service"]); - context = Context.add(context, AgentSessionIndexService, { - touch: () => Effect.succeed(undefined), - } as unknown as AgentSessionIndexService["Service"]); - context = Context.add(context, PaywallService, { - getPaywalls: () => Effect.succeed([]), - } as unknown as PaywallService["Service"]); - context = Context.add(context, PaywallWorkspaceService, {} as PaywallWorkspaceService["Service"]); - return context as Context.Context; + const withDb = Context.make(Db, serviceStub({})); + const withLocalUserSession = Context.add( + withDb, + LocalUserSessionService, + serviceStub({ + resolveLocalUser: () => Effect.succeed(authSession.user), + loadUserAccess: () => + Effect.succeed({ + organizations: authSession.organizations, + projects: authSession.projects, + }), + toUserSession: () => authSession, + }), + ); + const withIdentityProvider = Context.add( + withLocalUserSession, + IdentityProvider, + serviceStub({ + cookieName: "voidhash-session", + authenticateSessionCookie: () => Effect.succeed(null), + resolveIdentity: () => Effect.succeed(identity), + resolveIdentityById: () => Effect.succeed(identity), + linkExternalId: () => Effect.void, + }), + ); + const withSessionIndex = Context.add( + withIdentityProvider, + AgentSessionIndexService, + serviceStub({ touch: () => Effect.succeed(undefined) }), + ); + const withPaywalls = Context.add( + withSessionIndex, + PaywallService, + serviceStub({ getPaywalls: () => Effect.succeed([]) }), + ); + const services: Context.Context = Context.add( + withPaywalls, + PaywallWorkspaceService, + serviceStub({}), + ); + return services; }; -describe("installAgentNodeWebSocketServer", () => { - it("authenticates, streams, and accepts steering through a real Node WebSocket", async () => { - let releaseFirstProvider!: () => void; - const firstProviderGate = new Promise((resolve) => { - releaseFirstProvider = resolve; +const collectUserText = (entries: unknown): string[] => { + if (!Array.isArray(entries)) return []; + return entries + .filter((entry) => entry.type === "message" && entry.message?.role === "user") + .flatMap((entry) => { + const content = entry.message.content; + if (typeof content === "string") return [content]; + if (!Array.isArray(content)) return []; + return content.filter((part) => part.type === "text").map((part) => part.text); }); - let providerRequests = 0; - const provider = createServer((request, response) => { - if (request.url !== "/v1/responses") { - response.writeHead(404).end(); - return; - } - providerRequests += 1; - response.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - }); - response.write( - `data: ${JSON.stringify({ - type: "response.created", - response: { id: `response_${providerRequests}`, status: "in_progress", output: [] }, - })}\n\n`, - ); - const finishResponse = () => { - const events = [ - { - type: "response.output_item.added", - output_index: 0, - item: { - id: "message_1", - type: "message", - role: "assistant", - status: "in_progress", - content: [], - }, - }, - { type: "response.output_text.delta", output_index: 0, delta: "node-host-ok" }, - { - type: "response.output_item.done", - output_index: 0, - item: { - id: "message_1", - type: "message", - role: "assistant", - status: "completed", - content: [{ type: "output_text", text: "node-host-ok", annotations: [] }], +}; + +describe("installAgentNodeWebSocketServer", () => { + it("authenticates, streams, and accepts steering through a real Node WebSocket", () => + Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const firstProviderGate = yield* Latch.make(false); + let providerRequests = 0; + const provider = createServer((request, response) => { + if (request.url !== "/v1/responses") { + response.writeHead(404).end(); + return; + } + providerRequests += 1; + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }); + response.write( + `data: ${encodeJson({ + type: "response.created", + response: { id: `response_${providerRequests}`, status: "in_progress", output: [] }, + })}\n\n`, + ); + const finishResponse = () => { + const events = [ + { + type: "response.output_item.added", + output_index: 0, + item: { + id: "message_1", + type: "message", + role: "assistant", + status: "in_progress", + content: [], + }, + }, + { type: "response.output_text.delta", output_index: 0, delta: "node-host-ok" }, + { + type: "response.output_item.done", + output_index: 0, + item: { + id: "message_1", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "node-host-ok", annotations: [] }], + }, + }, + { + type: "response.completed", + response: { + id: "response_1", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]; + for (const event of events) response.write(`data: ${encodeJson(event)}\n\n`); + response.end("data: [DONE]\n\n"); + }; + if (providerRequests === 1) { + Effect.runFork( + firstProviderGate.await.pipe(Effect.flatMap(() => Effect.sync(finishResponse))), + ); + } else { + finishResponse(); + } + }); + const providerPort = yield* listen(provider); + + const server = createServer((_request, response) => response.writeHead(404).end()); + const host = installAgentNodeWebSocketServer( + server, + makeMemoryDurableEntityHost(), + makeServices(), + { + validateToken: () => + Effect.succeed({ + payload: { sub: "workos_user_1", email: "user@example.com" }, + provider: constant("workos"), + }), }, - }, - { - type: "response.completed", - response: { - id: "response_1", - status: "completed", - output: [], - usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + { + provider: "openai", + modelId: "gpt-5.4", + visionProvider: "openai", + visionModelId: "gpt-5.4", + openaiApiKey: Redacted.make("probe-key"), + openaiBaseUrl: `http://127.0.0.1:${providerPort}/v1`, }, - }, - ]; - for (const event of events) response.write(`data: ${JSON.stringify(event)}\n\n`); - response.end("data: [DONE]\n\n"); - }; - if (providerRequests === 1) { - void firstProviderGate.then(finishResponse); - } else { - finishResponse(); - } - }); - const providerPort = await listen(provider); + ); + const port = yield* listen(server); + const frames: Array> = []; + const socket = new WebSocket( + `ws://127.0.0.1:${port}/api/agent/sessions/agent_1/ws?organizationId=org_1&projectId=project_1&surface=designer`, + { headers: { authorization: "Bearer probe-token" } }, + ); + socket.on("message", (data) => frames.push(decodeFrame(data.toString()))); + yield* Effect.callback((resume) => { + socket.once("open", () => resume(Effect.void)); + socket.once("error", (error) => + resume(Effect.fail(new AgentNodeTestError({ message: causeMessage(error) }))), + ); + }); + socket.send(encodeJson({ v: 1, type: "prompt", requestId: "prompt_1", text: "hello" })); + yield* waitFor( + () => + providerRequests === 1 && + frames.some((frame) => frame.type === "event" && frame.event?.type === "agent_start"), + ); + socket.send(encodeJson({ v: 1, type: "get_state", requestId: "streaming_state" })); + yield* waitFor(() => + frames.some( + (frame) => + frame.type === "state" && + frame.requestId === "streaming_state" && + frame.state?.isStreaming === true, + ), + ); + socket.send( + encodeJson({ v: 1, type: "steer", requestId: "steer_1", text: "change direction" }), + ); + yield* waitFor(() => + frames.some( + (frame) => + frame.type === "ack" && frame.requestId === "steer_1" && frame.command === "steer", + ), + ); + yield* firstProviderGate.open; + yield* waitFor(() => + frames.some((frame) => frame.type === "event" && frame.event?.type === "agent_end"), + ); - const server = createServer((_request, response) => response.writeHead(404).end()); - const host = installAgentNodeWebSocketServer( - server, - makeMemoryDurableEntityHost(), - makeServices(), - { - validateToken: () => - Effect.succeed({ - payload: { sub: "workos_user_1", email: "user@example.com" }, - provider: "workos" as const, - }), - }, - { - provider: "openai", - modelId: "gpt-5.4", - visionProvider: "openai", - visionModelId: "gpt-5.4", - openaiApiKey: Redacted.make("probe-key"), - openaiBaseUrl: `http://127.0.0.1:${providerPort}/v1`, - }, - ); - const port = await listen(server); - const frames: Array> = []; - const socket = new WebSocket( - `ws://127.0.0.1:${port}/api/agent/sessions/agent_1/ws?organizationId=org_1&projectId=project_1&surface=designer`, - { headers: { authorization: "Bearer probe-token" } }, - ); - socket.on("message", (data) => frames.push(JSON.parse(data.toString()))); - await new Promise((resolve, reject) => { - socket.once("open", resolve); - socket.once("error", reject); - }); - socket.send(JSON.stringify({ v: 1, type: "prompt", requestId: "prompt_1", text: "hello" })); - await waitFor( - () => - providerRequests === 1 && - frames.some((frame) => frame.type === "event" && frame.event?.type === "agent_start"), - ); - socket.send(JSON.stringify({ v: 1, type: "get_state", requestId: "streaming_state" })); - await waitFor(() => - frames.some( - (frame) => - frame.type === "state" && - frame.requestId === "streaming_state" && - frame.state?.isStreaming === true, - ), - ); - socket.send( - JSON.stringify({ v: 1, type: "steer", requestId: "steer_1", text: "change direction" }), - ); - await waitFor(() => - frames.some( - (frame) => - frame.type === "ack" && frame.requestId === "steer_1" && frame.command === "steer", - ), - ); - releaseFirstProvider(); - await waitFor(() => - frames.some((frame) => frame.type === "event" && frame.event?.type === "agent_end"), - ); + const text = frames + .filter((frame) => frame.type === "event" && frame.event?.type === "message_end") + .flatMap((frame) => frame.event.message?.content ?? []) + .find((content) => content.type === "text")?.text; + expect(text).toBe("node-host-ok"); + expect(providerRequests).toBeGreaterThanOrEqual(2); - const text = frames - .filter((frame) => frame.type === "event" && frame.event?.type === "message_end") - .flatMap((frame) => frame.event.message?.content ?? []) - .find((content) => content.type === "text")?.text; - expect(text).toBe("node-host-ok"); - expect(providerRequests).toBeGreaterThanOrEqual(2); + socket.send(encodeJson({ v: 1, type: "get_entries", requestId: "entries_1" })); + yield* waitFor(() => + frames.some((frame) => frame.type === "entries" && frame.requestId === "entries_1"), + ); + const entries = frames.find( + (frame) => frame.type === "entries" && frame.requestId === "entries_1", + )?.entries; + expect(collectUserText(entries)).toContain("change direction"); - socket.send(JSON.stringify({ v: 1, type: "get_entries", requestId: "entries_1" })); - await waitFor(() => - frames.some((frame) => frame.type === "entries" && frame.requestId === "entries_1"), - ); - const entries = frames.find( - (frame) => frame.type === "entries" && frame.requestId === "entries_1", - )?.entries; - const userText = Array.isArray(entries) - ? entries - .filter((entry) => entry.type === "message" && entry.message?.role === "user") - .flatMap((entry) => { - const content = entry.message.content; - if (typeof content === "string") return [content]; - if (!Array.isArray(content)) return []; - return content.filter((part) => part.type === "text").map((part) => part.text); - }) - : []; - expect(userText).toContain("change direction"); - - socket.close(); - host.close(); - }); + socket.close(); + host.close(); + }), + ), + )); }); diff --git a/apps/backend/tests/Analytics.integration.test.ts b/apps/backend/tests/Analytics.integration.test.ts index 638302379..6a298d92b 100644 --- a/apps/backend/tests/Analytics.integration.test.ts +++ b/apps/backend/tests/Analytics.integration.test.ts @@ -1,4 +1,5 @@ import { EventCaptureService } from "@voidhash/core/services/analyticsIngest/EventCaptureService"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, apiKeys, @@ -8,7 +9,7 @@ import { projects, sql, } from "@voidhash/db"; -import { Effect } from "effect"; +import { Clock, DateTime, Effect } from "effect"; import { describe, expect, it } from "vitest"; import { @@ -18,82 +19,79 @@ import { import { getSelfhostRuntimeConfig } from "../src/config.ts"; describe("self-host analytics queue", () => { - it("captures, processes, and acknowledges an event without ClickHouse", async () => { - const config = getSelfhostRuntimeConfig(); - const suffix = crypto.randomUUID(); - const projectId = `project_capture_${suffix}`; - const token = `vh_pk_capture_${suffix.replaceAll("-", "")}`; - const database = Db.layer(config.database); - const program = Effect.scoped( + it("captures, processes, and acknowledges an event without ClickHouse", () => + Effect.runPromise( Effect.gen(function* () { - const db = yield* Db; - yield* db.insert(projects).values({ - id: projectId, - name: "Capture integration", - organizationId: `organization_${suffix}`, - slug: `capture-${suffix}`, - }); - yield* db.insert(apiKeys).values({ - end: token.slice(-4), - id: `apiKey_capture_${suffix}`, - isPublic: true, - key: token, - name: "Capture integration", - prefix: "vh_pk_", - projectId, - }); + const config = getSelfhostRuntimeConfig(); + const suffix = generateId("test"); + const projectId = `project_capture_${suffix}`; + const token = `vh_pk_capture_${suffix.replaceAll("-", "")}`; + const database = Db.layer(config.database); + const program = Effect.scoped( + Effect.gen(function* () { + const db = yield* Db; + const now = yield* DateTime.nowAsDate; + yield* db.insert(projects).values({ + id: projectId, + name: "Capture integration", + organizationId: `organization_${suffix}`, + slug: `capture-${suffix}`, + }); + yield* db.insert(apiKeys).values({ + end: token.slice(-4), + id: `apiKey_capture_${suffix}`, + isPublic: true, + key: token, + name: "Capture integration", + prefix: "vh_pk_", + projectId, + }); - yield* Effect.forkScoped(runSelfhostAnalyticsConsumers(config)); - const capture = yield* EventCaptureService; - const result = yield* capture.captureEvents({ - events: [ - { - context: {}, - distinct_id: `person_${suffix}`, - event: "selfhost_integration", - properties: { plan: "pro" }, - uuid: `event_${suffix}`, - }, - ], - request: { - headers: {}, - path: "/i/v1/capture", - receivedAt: new Date(), - requestId: `request_${suffix}`, - sentAt: new Date(), - token, - }, - }); - expect(result).toEqual({ accepted: 1, rejected: 0 }); + yield* Effect.forkScoped(runSelfhostAnalyticsConsumers(config)); + const capture = yield* EventCaptureService; + const result = yield* capture.captureEvents({ + events: [ + { + context: {}, + distinct_id: `person_${suffix}`, + event: "selfhost_integration", + properties: { plan: "pro" }, + uuid: `event_${suffix}`, + }, + ], + request: { + headers: {}, + path: "/i/v1/capture", + receivedAt: now, + requestId: `request_${suffix}`, + sentAt: now, + token, + }, + }); + expect(result).toEqual({ accepted: 1, rejected: 0 }); - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - const rows = yield* db.query.persons.findMany({ where: { projectId } }); - if (rows.length > 0) return rows.length; - yield* Effect.sleep("25 millis"); - } - return yield* Effect.die("analytics queue did not process the captured event"); - }).pipe( - Effect.provide(database), - Effect.provide(makeSelfhostAnalyticsRuntimeLive(config)), - ), - ); + const deadline = (yield* Clock.currentTimeMillis) + 10_000; + while ((yield* Clock.currentTimeMillis) < deadline) { + const rows = yield* db.query.persons.findMany({ where: { projectId } }); + if (rows.length > 0) return rows.length; + yield* Effect.sleep("25 millis"); + } + return yield* Effect.die("analytics queue did not process the captured event"); + }).pipe( + Effect.provide(database), + Effect.provide(makeSelfhostAnalyticsRuntimeLive(config)), + ), + ); - let count = 0; - try { - count = await Effect.runPromise(program); - } finally { - await Effect.runPromise( - Effect.gen(function* () { + const cleanup = Effect.gen(function* () { const db = yield* Db; yield* db.delete(apiKeys).where(eq(apiKeys.projectId, projectId)); yield* db.delete(personIdentities).where(eq(personIdentities.projectId, projectId)); yield* db.delete(persons).where(eq(persons.projectId, projectId)); yield* db.delete(projects).where(eq(projects.id, projectId)); - }).pipe(Effect.provide(database)), - ); - await Effect.runPromise( - Effect.gen(function* () { + }).pipe(Effect.provide(database), Effect.orDie); + + const cleanupQueue = Effect.gen(function* () { const db = yield* Db; // The cluster queue driver hands the store a JSON string, which the // store then JSON-encodes into `element`, so the body is doubly @@ -102,10 +100,14 @@ describe("self-host analytics queue", () => { DELETE FROM effect_queue WHERE (element::jsonb #>> '{}')::jsonb -> 'envelope' ->> 'projectId' = ${projectId} `); - }).pipe(Effect.provide(Db.layer(config.platformDatabase))), - ); - } + }).pipe(Effect.provide(Db.layer(config.platformDatabase)), Effect.orDie); + + const count = yield* program.pipe( + Effect.ensuring(cleanup), + Effect.ensuring(cleanupQueue), + ); - expect(count).toBe(1); - }); + expect(count).toBe(1); + }), + )); }); diff --git a/apps/backend/tests/BackendAdapters.test.ts b/apps/backend/tests/BackendAdapters.test.ts index b60c77e17..f48ff8b9f 100644 --- a/apps/backend/tests/BackendAdapters.test.ts +++ b/apps/backend/tests/BackendAdapters.test.ts @@ -1,22 +1,27 @@ import { ProjectSchemaCache } from "@voidhash/core/services"; import { Effect, Redacted } from "effect"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { MemoryProjectSchemaCacheLive } from "../src/backend/ProjectSchemaCache.ts"; import { getSelfhostMigrationDatabaseConfig, getSelfhostRuntimeConfig } from "../src/config.ts"; const originalEnvironment = { ...process.env }; -afterEach(() => { - process.env = { ...originalEnvironment }; -}); - -beforeEach(() => { - process.env.SELFHOST_MODE = "local-evaluation"; -}); +/** + * Runs a configuration test against a pristine environment. The environment is + * rebuilt before the body rather than restored by a lifecycle hook, so each case + * is isolated from whatever the previous one set. + */ +const configTest = (name: string, body: () => void): void => { + it(name, () => { + process.env = { ...originalEnvironment, SELFHOST_MODE: "local-evaluation" }; + body(); + process.env = { ...originalEnvironment }; + }); +}; describe("self-host runtime configuration", () => { - it("uses local development defaults", () => { + configTest("uses local development defaults", () => { delete process.env.NODE_ENV; delete process.env.ANTHROPIC_API_KEY; delete process.env.CLICKHOUSE_URL; @@ -58,7 +63,7 @@ describe("self-host runtime configuration", () => { expect(config.auth.rootUsername).toBe("root"); }); - it("reads BYO agent provider and model settings", () => { + configTest("reads BYO agent provider and model settings", () => { process.env.OPENAI_API_KEY = "configured-openai-key"; process.env.OPENAI_BASE_URL = "https://models.example.test/v1"; process.env.VOIDHASH_AGENT_MODEL_PROVIDER = "openai"; @@ -78,7 +83,7 @@ describe("self-host runtime configuration", () => { expect(Redacted.value(agent.openaiApiKey!)).toBe("configured-openai-key"); }); - it("reads authenticated TLS SMTP settings", () => { + configTest("reads authenticated TLS SMTP settings", () => { process.env.SMTP_HOST = "smtp.example.com"; process.env.SMTP_PORT = "465"; process.env.SMTP_SECURE = "true"; @@ -105,7 +110,7 @@ describe("self-host runtime configuration", () => { expect(Redacted.value(mailer.password!)).toBe("secret"); }); - it("accepts real root credentials in production", () => { + configTest("accepts real root credentials in production", () => { process.env.VOIDHASH_ROOT_USERNAME = "operator"; process.env.VOIDHASH_ROOT_PASSWORD = "a-real-root-password"; process.env.VOIDHASH_AUTH_SECRET = "a-real-session-signing-secret"; @@ -117,7 +122,7 @@ describe("self-host runtime configuration", () => { expect(Redacted.value(auth.rootPassword)).toBe("a-real-root-password"); }); - it("names every unconfigured standalone credential when production starts", () => { + configTest("names every unconfigured standalone credential when production starts", () => { process.env.NODE_ENV = "production"; process.env.SELFHOST_MODE = "production"; delete process.env.VOIDHASH_ROOT_USERNAME; @@ -127,14 +132,14 @@ describe("self-host runtime configuration", () => { expect(() => getSelfhostRuntimeConfig()).toThrow(/VOIDHASH_ROOT_USERNAME/); }); - it("supports an explicit plaintext connection for an internal Compose database", () => { + configTest("supports an explicit plaintext connection for an internal Compose database", () => { process.env.DATABASE_HOST = "postgres"; process.env.DATABASE_SSL = "false"; expect(getSelfhostRuntimeConfig().database).toMatchObject({ host: "postgres", ssl: false }); }); - it("falls back to the application connection for migrations", () => { + configTest("falls back to the application connection for migrations", () => { process.env.DATABASE_HOST = "postgres"; process.env.DATABASE_PORT = "6543"; process.env.DATABASE_NAME = "voidhash"; @@ -153,7 +158,7 @@ describe("self-host runtime configuration", () => { }); }); - it("overrides only the direct-TCP fields migrations need", () => { + configTest("overrides only the direct-TCP fields migrations need", () => { process.env.DATABASE_HOST = "broker.internal.local"; process.env.DATABASE_PORT = "5432"; process.env.DATABASE_NAME = "voidhash"; @@ -175,7 +180,7 @@ describe("self-host runtime configuration", () => { }); }); - it("enables ClickHouse only when its URL is configured", () => { + configTest("enables ClickHouse only when its URL is configured", () => { process.env.CLICKHOUSE_URL = "http://clickhouse:8123"; process.env.CLICKHOUSE_DATABASE = "analytics"; delete process.env.CLICKHOUSE_ADMIN_USERNAME; @@ -193,8 +198,8 @@ describe("self-host runtime configuration", () => { }); describe("memory project schema cache", () => { - it("stores, invalidates, and expires project schemas", async () => { - await Effect.runPromise( + it("stores, invalidates, and expires project schemas", () => + Effect.runPromise( Effect.gen(function* () { const cache = yield* ProjectSchemaCache; const project = cache.getByName("project-1"); @@ -208,6 +213,5 @@ describe("memory project schema cache", () => { yield* project.set({ version: 2 }, 0); expect(yield* project.get()).toBeUndefined(); }).pipe(Effect.provide(MemoryProjectSchemaCacheLive)), - ); - }); + )); }); diff --git a/apps/backend/tests/Background.integration.test.ts b/apps/backend/tests/Background.integration.test.ts index 249500c6d..58d78c5e6 100644 --- a/apps/backend/tests/Background.integration.test.ts +++ b/apps/backend/tests/Background.integration.test.ts @@ -2,17 +2,19 @@ import { type CronJob, CronScheduler } from "@voidhash/platform/CronScheduler"; import type { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { WorkflowRunner } from "@voidhash/platform/WorkflowRunner"; import * as TestWorkflowRunner from "@voidhash/platform/TestWorkflowRunner"; -import { Effect, Layer } from "effect"; +import { generateId } from "@voidhash/core/utils/generate-id"; +import { constant } from "@voidhash/lib/lang"; +import { Clock, DateTime, Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; import { makeSelfhostAnalyticsRuntimeLive } from "../src/backend/Analytics.ts"; import { makeSelfhostCronJobs } from "../src/backend/Background.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -const requiredJobNames = [ +const requiredJobNames = constant([ "AppStoreExpireParkedNotificationsWorkflow", "PurchaseLedgerDrainWorkflow", -] as const; +]); const twoDaysMillis = 2 * 24 * 60 * 60 * 1000; @@ -37,7 +39,7 @@ const runThroughScheduler = (job: CronJob) => let executions = 0; const probe: CronJob = { ...job, - name: `${job.name}-probe-${crypto.randomUUID()}`, + name: `${job.name}-probe-${generateId("test")}`, run: (context) => job.run(context).pipe( Effect.tap(() => @@ -47,39 +49,40 @@ const runThroughScheduler = (job: CronJob) => ), ), }; - const now = Date.now(); - yield* scheduler.tick(probe, new Date(now)); - yield* scheduler.tick(probe, new Date(now + twoDaysMillis)); + const now = yield* Clock.currentTimeMillis; + yield* scheduler.tick(probe, DateTime.toDateUtc(DateTime.makeUnsafe(now))); + yield* scheduler.tick(probe, DateTime.toDateUtc(DateTime.makeUnsafe(now + twoDaysMillis))); return executions; }); describe("self-host scheduled jobs", () => { - it("registers the required background jobs and executes them through the scheduler", async () => { - const testRunner = TestWorkflowRunner.make(); + it("registers the required background jobs and executes them through the scheduler", () => + Effect.runPromise( + Effect.gen(function* () { + const testRunner = TestWorkflowRunner.make(); - const outcome = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const jobs: ReadonlyArray> = - yield* makeSelfhostCronJobs(); - const registered = jobs.map((job) => job.name); - const executions: Record = {}; - for (const name of requiredJobNames) { - const job = jobs.find((candidate) => candidate.name === name); - if (job === undefined) continue; - executions[name] = yield* runThroughScheduler(job); - } - return { executions, registered }; - }).pipe( - Effect.provide(Layer.succeed(WorkflowRunner, testRunner)), - Effect.provide(makeSelfhostAnalyticsRuntimeLive(getSelfhostRuntimeConfig())), - ), - ), - ); + const outcome = yield* Effect.scoped( + Effect.gen(function* () { + const jobs: ReadonlyArray> = + yield* makeSelfhostCronJobs(); + const registered = jobs.map((job) => job.name); + const executions: Record = {}; + for (const name of requiredJobNames) { + const job = jobs.find((candidate) => candidate.name === name); + if (job === undefined) continue; + executions[name] = yield* runThroughScheduler(job); + } + return { executions, registered }; + }).pipe( + Effect.provide(Layer.succeed(WorkflowRunner, testRunner)), + Effect.provide(makeSelfhostAnalyticsRuntimeLive(getSelfhostRuntimeConfig())), + ), + ); - expect(outcome.registered).toEqual(expect.arrayContaining([...requiredJobNames])); - for (const name of requiredJobNames) { - expect(outcome.executions[name]).toBeGreaterThanOrEqual(1); - } - }); + expect(outcome.registered).toEqual(expect.arrayContaining([...requiredJobNames])); + for (const name of requiredJobNames) { + expect(outcome.executions[name]).toBeGreaterThanOrEqual(1); + } + }), + )); }); diff --git a/apps/backend/tests/Clickhouse.integration.test.ts b/apps/backend/tests/Clickhouse.integration.test.ts index 7861e217c..4d48bbfe9 100644 --- a/apps/backend/tests/Clickhouse.integration.test.ts +++ b/apps/backend/tests/Clickhouse.integration.test.ts @@ -7,6 +7,7 @@ import { } from "@voidhash/clickhouse-db/analytics/schema"; import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; import { EventCaptureService } from "@voidhash/core/services/analyticsIngest/EventCaptureService"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { apiKeys, Db, @@ -16,7 +17,8 @@ import { projects, sql as pgSql, } from "@voidhash/db"; -import { Context, Effect, Layer } from "effect"; +import { constant } from "@voidhash/lib/lang"; +import { Clock, Context, Data, DateTime, Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; import { @@ -29,155 +31,166 @@ import { } from "../src/backend/Clickhouse.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -const analyticsTables = [ +const analyticsTables = constant([ CLICKHOUSE_EVENTS_TABLE, CLICKHOUSE_PERSONS_TABLE, CLICKHOUSE_PERSON_IDENTITY_TABLE, CLICKHOUSE_PERSON_IDENTITY_OVERRIDES_TABLE, CLICKHOUSE_PERSON_IDENTITY_PENDING_OVERRIDES_V2_TABLE, -] as const; +]); + +class MissingClickhouseConfigError extends Data.TaggedError("MissingClickhouseConfigError")<{ + readonly message: string; +}> {} const countEvents = ( layer: Layer.Layer, projectId: string, organizationId?: string, ) => - Effect.runPromise( - Effect.gen(function* () { - const ch = yield* ClickhouseWebClient.ClickhouseWebClient; - const query = ch<{ readonly total: string }>` + Effect.gen(function* () { + const ch = yield* ClickhouseWebClient.ClickhouseWebClient; + const query = ch<{ readonly total: string }>` SELECT count() AS total FROM ${ch.literal(CLICKHOUSE_EVENTS_TABLE)} WHERE project_id = ${ch.param("String", projectId)} `; - const rows = yield* organizationId - ? ch.withClickhouseSettings(query, { SQL_organization_id: organizationId }) - : query; + if (!organizationId) { + const rows = yield* query; return Number(rows[0]?.total ?? 0); - }).pipe(Effect.provide(layer), Effect.scoped), - ); + } + const rows = yield* ch.withClickhouseSettings(query, { + SQL_organization_id: organizationId, + }); + return Number(rows[0]?.total ?? 0); + }).pipe(Effect.provide(layer), Effect.scoped); describe("self-host ClickHouse analytics", () => { - it("writes captured events and enforces the runtime access split", async () => { - const config = getSelfhostRuntimeConfig(); - if (!config.clickhouse) throw new Error("CLICKHOUSE_URL is required for this test"); - await Effect.runPromise(migrateSelfhostClickhouse(config.clickhouse)); - const clickhouse = makeSelfhostClickhouseLayers(config.clickhouse); - const database = Db.layer(config.database); - const suffix = crypto.randomUUID(); - const projectId = `project_clickhouse_${suffix}`; - const organizationId = `organization_clickhouse_${suffix}`; - const token = `vh_pk_clickhouse_${suffix.replaceAll("-", "")}`; + it("writes captured events and enforces the runtime access split", () => + Effect.runPromise( + Effect.gen(function* () { + const config = getSelfhostRuntimeConfig(); + const clickhouseConfig = config.clickhouse; + if (!clickhouseConfig) { + return yield* new MissingClickhouseConfigError({ + message: "CLICKHOUSE_URL is required for this test", + }); + } + yield* migrateSelfhostClickhouse(clickhouseConfig); + const clickhouse = makeSelfhostClickhouseLayers(clickhouseConfig); + const database = Db.layer(config.database); + const suffix = generateId("test"); + const projectId = `project_clickhouse_${suffix}`; + const organizationId = `organization_clickhouse_${suffix}`; + const token = `vh_pk_clickhouse_${suffix.replaceAll("-", "")}`; - try { - const written = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { + const teardown = Effect.gen(function* () { + yield* Effect.gen(function* () { + const ch = yield* ClickhouseWebClient.ClickhouseWebClient; + yield* Effect.forEach( + analyticsTables, + (table) => + ch.asCommand(ch` + ALTER TABLE ${ch(table)} DELETE + WHERE project_id = ${projectId} + `), + { discard: true }, + ); + }).pipe(Effect.provide(clickhouse.readWrite), Effect.scoped); + yield* Effect.gen(function* () { const db = yield* Db; - yield* db.insert(projects).values({ - id: projectId, - name: "ClickHouse integration", - organizationId, - slug: `clickhouse-${suffix}`, - }); - yield* db.insert(apiKeys).values({ - end: token.slice(-4), - id: `apiKey_clickhouse_${suffix}`, - isPublic: true, - key: token, - name: "ClickHouse integration", - prefix: "vh_pk_", - projectId, - }); + yield* db.delete(apiKeys).where(eq(apiKeys.projectId, projectId)); + yield* db.delete(personIdentities).where(eq(personIdentities.projectId, projectId)); + yield* db.delete(persons).where(eq(persons.projectId, projectId)); + yield* db.delete(projects).where(eq(projects.id, projectId)); + }).pipe(Effect.provide(database)); + yield* Effect.gen(function* () { + const db = yield* Db; + // The cluster queue driver hands the store a JSON string, which the + // store then JSON-encodes into `element`, so the body is doubly + // encoded: unwrap the outer JSON scalar before reading its fields. + yield* db.execute(pgSql` + DELETE FROM effect_queue + WHERE (element::jsonb #>> '{}')::jsonb -> 'envelope' ->> 'projectId' = ${projectId} + `); + }).pipe(Effect.provide(Db.layer(config.platformDatabase))); + }).pipe(Effect.orDie); - yield* Effect.forkScoped( - runSelfhostAnalyticsConsumers(config, clickhouse.readWrite), - ); - const capture = yield* EventCaptureService; - yield* capture.captureEvents({ - events: [ - { - context: {}, - distinct_id: `person_${suffix}`, - event: "selfhost_clickhouse_integration", - properties: { plan: "pro" }, - uuid: `event_${suffix}`, + return yield* Effect.gen(function* () { + const written = yield* Effect.scoped( + Effect.gen(function* () { + const db = yield* Db; + yield* db.insert(projects).values({ + id: projectId, + name: "ClickHouse integration", + organizationId, + slug: `clickhouse-${suffix}`, + }); + yield* db.insert(apiKeys).values({ + end: token.slice(-4), + id: `apiKey_clickhouse_${suffix}`, + isPublic: true, + key: token, + name: "ClickHouse integration", + prefix: "vh_pk_", + projectId, + }); + + yield* Effect.forkScoped( + runSelfhostAnalyticsConsumers(config, clickhouse.readWrite), + ); + const capture = yield* EventCaptureService; + const now = yield* DateTime.nowAsDate; + yield* capture.captureEvents({ + events: [ + { + context: {}, + distinct_id: `person_${suffix}`, + event: "selfhost_clickhouse_integration", + properties: { plan: "pro" }, + uuid: `event_${suffix}`, + }, + ], + request: { + headers: {}, + path: "/i/v1/capture", + receivedAt: now, + requestId: `request_${suffix}`, + sentAt: now, + token, }, - ], - request: { - headers: {}, - path: "/i/v1/capture", - receivedAt: new Date(), - requestId: `request_${suffix}`, - sentAt: new Date(), - token, - }, - }); + }); - const readWriteContext = yield* Layer.build(clickhouse.readWrite); - const ch = Context.get( - readWriteContext, - ClickhouseWebClient.ClickhouseWebClient, - ); - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - const rows = yield* ch<{ readonly total: string }>` + const readWriteContext = yield* Layer.build(clickhouse.readWrite); + const ch = Context.get( + readWriteContext, + ClickhouseWebClient.ClickhouseWebClient, + ); + const deadline = (yield* Clock.currentTimeMillis) + 10_000; + while ((yield* Clock.currentTimeMillis) < deadline) { + const rows = yield* ch<{ readonly total: string }>` SELECT count() AS total FROM ${ch.literal(CLICKHOUSE_EVENTS_TABLE)} WHERE project_id = ${ch.param("String", projectId)} `; - if (Number(rows[0]?.total ?? 0) > 0) return Number(rows[0]?.total); - yield* Effect.sleep("25 millis"); - } - return yield* Effect.die("analytics event did not land in ClickHouse"); - }).pipe( - Effect.provide(database), - Effect.provide(makeSelfhostAnalyticsRuntimeLive(config)), - Effect.provide(clickhouse.readOnly), - ), - ), - ); + if (Number(rows[0]?.total ?? 0) > 0) return Number(rows[0]?.total); + yield* Effect.sleep("25 millis"); + } + return yield* Effect.die("analytics event did not land in ClickHouse"); + }).pipe( + Effect.provide(database), + Effect.provide(makeSelfhostAnalyticsRuntimeLive(config)), + Effect.provide(clickhouse.readOnly), + ), + ); - expect(written).toBe(1); - expect(await countEvents(clickhouse.readOnly, projectId, organizationId)).toBe(1); - expect(await countEvents(clickhouse.readOnly, projectId, "another-organization")).toBe(0); - expect(await countEvents(clickhouse.analyticsQuery, projectId)).toBe(1); - } finally { - await Effect.runPromise( - Effect.gen(function* () { - const ch = yield* ClickhouseWebClient.ClickhouseWebClient; - yield* Effect.forEach( - analyticsTables, - (table) => - ch.asCommand(ch` - ALTER TABLE ${ch(table)} DELETE - WHERE project_id = ${projectId} - `), - { discard: true }, + expect(written).toBe(1); + expect(yield* countEvents(clickhouse.readOnly, projectId, organizationId)).toBe(1); + expect(yield* countEvents(clickhouse.readOnly, projectId, "another-organization")).toBe( + 0, ); - }).pipe(Effect.provide(clickhouse.readWrite), Effect.scoped), - ); - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - yield* db.delete(apiKeys).where(eq(apiKeys.projectId, projectId)); - yield* db.delete(personIdentities).where(eq(personIdentities.projectId, projectId)); - yield* db.delete(persons).where(eq(persons.projectId, projectId)); - yield* db.delete(projects).where(eq(projects.id, projectId)); - }).pipe(Effect.provide(database)), - ); - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - // The cluster queue driver hands the store a JSON string, which the - // store then JSON-encodes into `element`, so the body is doubly - // encoded: unwrap the outer JSON scalar before reading its fields. - yield* db.execute(pgSql` - DELETE FROM effect_queue - WHERE (element::jsonb #>> '{}')::jsonb -> 'envelope' ->> 'projectId' = ${projectId} - `); - }).pipe(Effect.provide(Db.layer(config.platformDatabase))), - ); - } - }, 30_000); + expect(yield* countEvents(clickhouse.analyticsQuery, projectId)).toBe(1); + }).pipe(Effect.ensuring(teardown)); + }), + ), 30_000); }); diff --git a/apps/backend/tests/Compiler.test.ts b/apps/backend/tests/Compiler.test.ts index 2765d83d3..6d8fe0a53 100644 --- a/apps/backend/tests/Compiler.test.ts +++ b/apps/backend/tests/Compiler.test.ts @@ -17,49 +17,56 @@ const validComponent = ` `; describe("self-host component compiler", () => { - it("compiles and extracts a component manifest", async () => { - const result = await Effect.runPromise(compiler.compileAndExtract(validComponent)); + it("compiles and extracts a component manifest", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* compiler.compileAndExtract(validComponent); - expect(result.status).toBe("ready"); - if (result.status === "ready") { - expect(result.manifest).toMatchObject({ manifestVersion: 2, title: "Hero" }); - expect(result.previewTrees.default).toMatchObject({ - treeVersion: 2, - state: "default", - root: { type: "text", text: "Go Pro", style: {} }, - }); - } - }); + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.manifest).toMatchObject({ manifestVersion: 2, title: "Hero" }); + expect(result.previewTrees.default).toMatchObject({ + treeVersion: 2, + state: "default", + root: { type: "text", text: "Go Pro", style: {} }, + }); + } + }), + )); - it("classifies source and runtime failures without escaping", async () => { - const compile = await Effect.runPromise( - compiler.compileAndExtract("export default function Hero() { return ; }"), - ); - const runtime = await Effect.runPromise( - compiler.compileAndExtract('throw new Error("boom"); export default {};'), - ); + it("classifies source and runtime failures without escaping", () => + Effect.runPromise( + Effect.gen(function* () { + const compile = yield* compiler.compileAndExtract( + "export default function Hero() { return ; }", + ); + const runtime = yield* compiler.compileAndExtract( + 'throw new Error("boom"); export default {};', + ); - expect(compile).toMatchObject({ phase: "compile", status: "error" }); - expect(runtime).toMatchObject({ phase: "runtime", status: "error" }); - }); + expect(compile).toMatchObject({ phase: "compile", status: "error" }); + expect(runtime).toMatchObject({ phase: "runtime", status: "error" }); + }), + )); - it("bounds evaluation and disables dynamic code generation", async () => { - const loop = await Effect.runPromise( - compiler.compileAndExtract("while (true) {} export default {};"), - ); - const dynamicCode = await Effect.runPromise( - compiler.compileAndExtract('Function("return 1")(); export default {};'), - ); + it("bounds evaluation and disables dynamic code generation", () => + Effect.runPromise( + Effect.gen(function* () { + const loop = yield* compiler.compileAndExtract("while (true) {} export default {};"); + const dynamicCode = yield* compiler.compileAndExtract( + 'Function("return 1")(); export default {};', + ); - expect(loop).toMatchObject({ phase: "runtime", status: "error" }); - expect(dynamicCode).toMatchObject({ phase: "runtime", status: "error" }); - if (loop.status === "error") { - expect(loop.diagnostics[0]?.message).toContain("timed out"); - } - if (dynamicCode.status === "error") { - expect(dynamicCode.diagnostics[0]?.message).toContain( - "Code generation from strings disallowed", - ); - } - }); + expect(loop).toMatchObject({ phase: "runtime", status: "error" }); + expect(dynamicCode).toMatchObject({ phase: "runtime", status: "error" }); + if (loop.status === "error") { + expect(loop.diagnostics[0]?.message).toContain("timed out"); + } + if (dynamicCode.status === "error") { + expect(dynamicCode.diagnostics[0]?.message).toContain( + "Code generation from strings disallowed", + ); + } + }), + )); }); diff --git a/apps/backend/tests/CompilerClient.integration.test.ts b/apps/backend/tests/CompilerClient.integration.test.ts index 47470e025..22986e7da 100644 --- a/apps/backend/tests/CompilerClient.integration.test.ts +++ b/apps/backend/tests/CompilerClient.integration.test.ts @@ -1,19 +1,24 @@ import { ComponentCompiler } from "@voidhash/core/services/paywallWorkspace/ComponentCompiler"; -import { Effect } from "effect"; +import { Config, Effect } from "effect"; import { describe, expect, it } from "vitest"; import { makeHttpComponentCompilerLive } from "../src/compiler/CompilerClient.ts"; // The compiler is part of the provisioned stack, so a missing URL is a broken // environment rather than a reason to skip. -const compilerUrl = process.env.SELFHOST_COMPILER_URL ?? "http://127.0.0.1:5002"; +const compilerUrl = Config.string("SELFHOST_COMPILER_URL").pipe( + Config.withDefault("http://127.0.0.1:5002"), + Effect.orDie, +); describe("self-host component compiler client", () => { - it("round-trips compile and extraction results through HTTP", async () => { - const result = await Effect.runPromise( + it("round-trips compile and extraction results through HTTP", () => + Effect.runPromise( Effect.gen(function* () { - const compiler = yield* ComponentCompiler; - return yield* compiler.compileAndExtract(` + const url = yield* compilerUrl; + const result = yield* Effect.gen(function* () { + const compiler = yield* ComponentCompiler; + return yield* compiler.compileAndExtract(` import { defineComponent } from "@voidhash/paywalls"; export default defineComponent({ title: "Client Card", @@ -22,13 +27,13 @@ describe("self-host component compiler client", () => { previews: { default: {} }, render: () => null, }); - `); - }).pipe(Effect.provide(makeHttpComponentCompilerLive(compilerUrl ?? ""))), - ); + `); + }).pipe(Effect.provide(makeHttpComponentCompilerLive(url))); - expect(result.status).toBe("ready"); - if (result.status === "ready") { - expect(result.manifest).toMatchObject({ manifestVersion: 2, title: "Client Card" }); - } - }); + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.manifest).toMatchObject({ manifestVersion: 2, title: "Client Card" }); + } + }), + )); }); diff --git a/apps/backend/tests/MimicDocumentIdle.test.ts b/apps/backend/tests/MimicDocumentIdle.test.ts index ee03d0983..f18464e26 100644 --- a/apps/backend/tests/MimicDocumentIdle.test.ts +++ b/apps/backend/tests/MimicDocumentIdle.test.ts @@ -23,121 +23,118 @@ const control: DurableEntityAlarmControlShape = { }; describe("Mimic Node idle alarm dispatch", () => { - it("samples the current time whenever a reused dispatch effect runs", async () => { - const observed: number[] = []; - const clock = vi.spyOn(Date, "now").mockReturnValue(100); - const dispatch = dispatchMimicDocumentIdleAlarms( - makeMemoryDurableEntityHost(), - { - control: { - listDueAlarms: (now) => + it("samples the current time whenever a reused dispatch effect runs", () => + Effect.runPromise( + Effect.gen(function* () { + const observed: number[] = []; + const clock = vi.spyOn(Date, "now").mockReturnValue(100); + const dispatch = dispatchMimicDocumentIdleAlarms( + makeMemoryDurableEntityHost(), + { + control: { + listDueAlarms: (now) => + Effect.sync(() => { + observed.push(now); + return []; + }), + }, + debounceMs: 1, + publish: () => Effect.void, + }, + () => 0, + ); + + yield* Effect.gen(function* () { + yield* dispatch; + clock.mockReturnValue(200); + yield* dispatch; + }).pipe( + Effect.ensuring( Effect.sync(() => { - observed.push(now); - return []; + clock.mockRestore(); }), - }, - debounceMs: 1, - publish: () => Effect.void, - }, - () => 0, - ); - - try { - await Effect.runPromise(dispatch); - clock.mockReturnValue(200); - await Effect.runPromise(dispatch); - } finally { - clock.mockRestore(); - } + ), + ); - expect(observed).toEqual([100, 200]); - }); + expect(observed).toEqual([100, 200]); + }), + )); - it("publishes and records a persisted dirty revision", async () => { - const entities = makeMemoryDurableEntityHost(); - const published: MimicDocumentIdleMessageType[] = []; - await Effect.runPromise( - entities.run(address, (entity) => - Effect.gen(function* () { - yield* entity.keyValue.put(IDLE_DIRTY_SEQ_KEY, 7); - yield* entity.keyValue.put(IDLE_NOTIFIED_SEQ_KEY, 4); - yield* entity.alarm.set(0); - }), - ), - ); + it("publishes and records a persisted dirty revision", () => + Effect.runPromise( + Effect.gen(function* () { + const entities = makeMemoryDurableEntityHost(); + const published: MimicDocumentIdleMessageType[] = []; + yield* entities.run(address, (entity) => + Effect.gen(function* () { + yield* entity.keyValue.put(IDLE_DIRTY_SEQ_KEY, 7); + yield* entity.keyValue.put(IDLE_NOTIFIED_SEQ_KEY, 4); + yield* entity.alarm.set(0); + }), + ); - await Effect.runPromise( - dispatchMimicDocumentIdleAlarms( - entities, - { - control, - debounceMs: 1, - publish: (message) => - Effect.sync(() => { - published.push(message); - }), - }, - () => 0, - ), - ); + yield* dispatchMimicDocumentIdleAlarms( + entities, + { + control, + debounceMs: 1, + publish: (message) => + Effect.sync(() => { + published.push(message); + }), + }, + () => 0, + ); - expect(published).toEqual([ - { collectionId: "collection-1", documentId: "document-1", seq: 7 }, - ]); - expect( - await Effect.runPromise( - entities.run(address, (entity) => - entity.keyValue.get(IDLE_NOTIFIED_SEQ_KEY), - ), - ), - ).toBe(7); - expect( - await Effect.runPromise( - entities.run(address, (entity) => entity.alarm.get), - ), - ).toBeUndefined(); - }); + expect(published).toEqual([ + { collectionId: "collection-1", documentId: "document-1", seq: 7 }, + ]); + expect( + yield* entities.run(address, (entity) => + entity.keyValue.get(IDLE_NOTIFIED_SEQ_KEY), + ), + ).toBe(7); + expect( + yield* entities.run(address, (entity) => entity.alarm.get), + ).toBeUndefined(); + }), + )); - it("consumes the alarm without publishing when a collaborator reconnected", async () => { - const entities = makeMemoryDurableEntityHost(); - const published: MimicDocumentIdleMessageType[] = []; - await Effect.runPromise( - entities.run(address, (entity) => - Effect.gen(function* () { - yield* entity.keyValue.put(IDLE_DIRTY_SEQ_KEY, 8); - yield* entity.keyValue.put(IDLE_NOTIFIED_SEQ_KEY, 7); - yield* entity.alarm.set(0); - }), - ), - ); + it("consumes the alarm without publishing when a collaborator reconnected", () => + Effect.runPromise( + Effect.gen(function* () { + const entities = makeMemoryDurableEntityHost(); + const published: MimicDocumentIdleMessageType[] = []; + yield* entities.run(address, (entity) => + Effect.gen(function* () { + yield* entity.keyValue.put(IDLE_DIRTY_SEQ_KEY, 8); + yield* entity.keyValue.put(IDLE_NOTIFIED_SEQ_KEY, 7); + yield* entity.alarm.set(0); + }), + ); - await Effect.runPromise( - dispatchMimicDocumentIdleAlarms( - entities, - { - control, - debounceMs: 1, - publish: (message) => - Effect.sync(() => { - published.push(message); - }), - }, - () => 1, - ), - ); + yield* dispatchMimicDocumentIdleAlarms( + entities, + { + control, + debounceMs: 1, + publish: (message) => + Effect.sync(() => { + published.push(message); + }), + }, + () => 1, + ); - expect(published).toEqual([]); - expect( - await Effect.runPromise( - entities.run(address, (entity) => - entity.keyValue.get(IDLE_NOTIFIED_SEQ_KEY), - ), - ), - ).toBe(7); - expect( - await Effect.runPromise( - entities.run(address, (entity) => entity.alarm.get), - ), - ).toBeUndefined(); - }); + expect(published).toEqual([]); + expect( + yield* entities.run(address, (entity) => + entity.keyValue.get(IDLE_NOTIFIED_SEQ_KEY), + ), + ).toBe(7); + expect( + yield* entities.run(address, (entity) => entity.alarm.get), + ).toBeUndefined(); + }), + )); }); diff --git a/apps/backend/tests/MimicNode.integration.test.ts b/apps/backend/tests/MimicNode.integration.test.ts index e47667437..b949eaa62 100644 --- a/apps/backend/tests/MimicNode.integration.test.ts +++ b/apps/backend/tests/MimicNode.integration.test.ts @@ -1,6 +1,7 @@ import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; +import { generateId } from "@voidhash/core/utils/generate-id"; +import { causeMessage } from "@voidhash/lib/lang"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; import type { SchemaObject, Value } from "@voidhash/mimic-core"; import { @@ -10,42 +11,41 @@ import { } from "@voidhash/platform/DurableEntity"; import { PgClusterDurableEntityLive } from "@voidhash/platform-selfhost/ClusterDurableEntity"; import type { PgPlatformConfig } from "@voidhash/platform-selfhost/Postgres"; -import { Effect, ManagedRuntime, Redacted } from "effect"; +import { Config, Data, Effect, Layer, ManagedRuntime, Redacted, Schema } from "effect"; import { describe, expect, it } from "vitest"; import WebSocket from "ws"; import { makeMimicNodeHostLive, type MimicNodeConfig } from "../src/mimic/MimicNode.ts"; import { installMimicNodeWebSocketServer } from "../src/mimic/MimicNodeWebSocket.ts"; -const config: MimicNodeConfig = { - database: { - host: process.env.SELFHOST_PG_HOST ?? "127.0.0.1", - port: Number(process.env.SELFHOST_PG_PORT ?? "5432"), - database: process.env.SELFHOST_PG_DATABASE ?? "voidhash", - username: process.env.SELFHOST_PG_USERNAME ?? "voidhash", - password: Redacted.make(process.env.SELFHOST_PG_PASSWORD ?? "password"), - }, - documents: { - host: process.env.SELFHOST_PG_HOST ?? "127.0.0.1", - port: Number(process.env.SELFHOST_PG_PORT ?? "5432"), - database: process.env.SELFHOST_PG_DATABASE ?? "voidhash", - username: process.env.SELFHOST_PG_USERNAME ?? "voidhash", - password: Redacted.make(process.env.SELFHOST_PG_PASSWORD ?? "password"), - }, -}; +class MimicNodeTestError extends Data.TaggedError("MimicNodeTestError")<{ + readonly message: string; +}> {} -// The entity host runs a single-node cluster, which claims every shard in the -// database it is built over. Pointing it at the platform test database keeps it -// from stealing messages addressed to the deployment this suite runs against; -// control and document state stay in the application database above. -const platformConfig: PgPlatformConfig = { - host: process.env.PLATFORM_SELFHOST_PG_HOST ?? "127.0.0.1", - port: Number(process.env.PLATFORM_SELFHOST_PG_PORT ?? "5432"), - database: process.env.PLATFORM_SELFHOST_PG_DATABASE ?? "voidhash", - username: process.env.PLATFORM_SELFHOST_PG_USERNAME ?? "voidhash", - password: Redacted.make(process.env.PLATFORM_SELFHOST_PG_PASSWORD ?? "password"), +const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString); +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); + +const messageType = (message: unknown): string | undefined => { + if (typeof message !== "object" || message === null) return undefined; + if (!("type" in message)) return undefined; + if (typeof message.type !== "string") return undefined; + return message.type; }; +const readPgConfig = (prefix: string) => + Effect.gen(function* () { + const config: PgPlatformConfig = { + host: yield* Config.string(`${prefix}_PG_HOST`).pipe(Config.withDefault("127.0.0.1")), + port: yield* Config.int(`${prefix}_PG_PORT`).pipe(Config.withDefault(5432)), + database: yield* Config.string(`${prefix}_PG_DATABASE`).pipe(Config.withDefault("voidhash")), + username: yield* Config.string(`${prefix}_PG_USERNAME`).pipe(Config.withDefault("voidhash")), + password: yield* Config.redacted(`${prefix}_PG_PASSWORD`).pipe( + Config.withDefault(Redacted.make("password")), + ), + }; + return config; + }); + const schema: SchemaObject = { kind: "object", fields: { @@ -59,149 +59,179 @@ const value: Value = { // Every build owns its own single-node cluster, which is what makes the // restart assertions meaningful: nothing process-local carries over. +// +// The entity host runs a single-node cluster, which claims every shard in the +// database it is built over. Pointing it at the platform test database keeps it +// from stealing messages addressed to the deployment this suite runs against; +// control and document state stay in the application database. const hostLayer = () => - makeMimicNodeHostLive(config, PgClusterDurableEntityLive(platformConfig)); - -const runHost = (program: Effect.Effect): Promise => - Effect.runPromise( - Effect.scoped(program.pipe(Effect.provide(hostLayer()))) as Effect.Effect, + Layer.unwrap( + Effect.gen(function* () { + const database = yield* readPgConfig("SELFHOST"); + const platformConfig = yield* readPgConfig("PLATFORM_SELFHOST"); + const config: MimicNodeConfig = { database, documents: database }; + return makeMimicNodeHostLive(config, PgClusterDurableEntityLive(platformConfig)); + }), ); -const runStandalone = (program: Effect.Effect): Promise => - Effect.runPromise(program as Effect.Effect); +type MimicNodeHostServices = HostServiceTag | DurableEntityHost | DurableEntityAlarmControl; + +const runHost = (program: Effect.Effect) => + Effect.scoped(program.pipe(Effect.provide(hostLayer()))); describe("self-host mimic Node composition", () => { - it("restores control and document state after the host layer restarts", async () => { - const suffix = crypto.randomUUID(); - const created = await runHost( + it("restores control and document state after the host layer restarts", () => + Effect.runPromise( Effect.gen(function* () { - const host = yield* HostServiceTag; - const database = yield* host.createDatabase(`restart-${suffix}`, "integration"); - const collection = yield* host.createCollection(database.id, "documents", schema); - const document = yield* host.createDocument(collection.id, undefined, value); - return { database, collection, document }; - }), - ); + const suffix = generateId("test"); + const created = yield* runHost( + Effect.gen(function* () { + const host = yield* HostServiceTag; + const database = yield* host.createDatabase(`restart-${suffix}`, "integration"); + const collection = yield* host.createCollection(database.id, "documents", schema); + const document = yield* host.createDocument(collection.id, undefined, value); + return { database, collection, document }; + }), + ); - const restored = await runHost( - Effect.gen(function* () { - const host = yield* HostServiceTag; - return yield* host.getDocument(created.collection.id, created.document.id); - }), - ); + const restored = yield* runHost( + Effect.gen(function* () { + const host = yield* HostServiceTag; + return yield* host.getDocument(created.collection.id, created.document.id); + }), + ); - expect(restored).toEqual(created.document); + expect(restored).toEqual(created.document); - await runHost( - Effect.gen(function* () { - const host = yield* HostServiceTag; - yield* host.deleteDocument(created.collection.id, created.document.id); - yield* host.deleteCollection(created.collection.id); - yield* host.deleteDatabase(created.database.id); + yield* runHost( + Effect.gen(function* () { + const host = yield* HostServiceTag; + yield* host.deleteDocument(created.collection.id, created.document.id); + yield* host.deleteCollection(created.collection.id); + yield* host.deleteDatabase(created.database.id); + }), + ); }), - ); - }); + )); - it("serves the document auth and snapshot protocol over a real Node WebSocket", async () => { - const runtime = ManagedRuntime.make(hostLayer()); - const host = await runtime.runPromise(HostServiceTag); - const entities = await runtime.runPromise(DurableEntityHost); - const entityControl = await runtime.runPromise(DurableEntityAlarmControl); - const server = createServer(); - const closeWebSockets = installMimicNodeWebSocketServer(server, host, entities, { - control: entityControl, - debounceMs: 15_000, - pollIntervalMs: 60_000, - publish: () => Effect.void, - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - - const suffix = crypto.randomUUID(); - const documentId = `ws-${suffix.slice(0, 20)}`; - let databaseId: string | undefined; - let collectionId: string | undefined; - try { - const created = await runStandalone( - Effect.gen(function* () { - const database = yield* host.createDatabase(`ws-${suffix}`, "integration"); - const collection = yield* host.createCollection(database.id, "documents", schema); - const document = yield* host.createDocument(collection.id, documentId, value); - const auth = yield* host.createDocumentAuthToken( - collection.id, - document.id, - "write", - [], - 60, - ); - return { database, collection, document, auth }; - }), - ); - databaseId = created.database.id; - collectionId = created.collection.id; - const address = server.address() as AddressInfo; - const socket = new WebSocket( - `ws://127.0.0.1:${address.port}/ws/v1/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, - ); - const messages = await new Promise((resolve, reject) => { - const received: unknown[] = []; - const timeout = setTimeout(() => reject(new Error("timed out waiting for snapshot")), 5_000); - socket.once("error", reject); - socket.once("open", () => - socket.send(JSON.stringify({ type: "auth", token: created.auth.token })), + it("serves the document auth and snapshot protocol over a real Node WebSocket", () => + Effect.runPromise( + Effect.gen(function* () { + const runtime = ManagedRuntime.make(hostLayer()); + const host = yield* Effect.promise(() => runtime.runPromise(HostServiceTag)); + const entities = yield* Effect.promise(() => runtime.runPromise(DurableEntityHost)); + const entityControl = yield* Effect.promise(() => + runtime.runPromise(DurableEntityAlarmControl), ); - socket.on("message", (data) => { - const message = JSON.parse(data.toString()) as { readonly type?: string }; - received.push(message); - if (message.type === "snapshot") { - clearTimeout(timeout); - resolve(received); - } + const server = createServer(); + const closeWebSockets = installMimicNodeWebSocketServer(server, host, entities, { + control: entityControl, + debounceMs: 15_000, + pollIntervalMs: 60_000, + publish: () => Effect.void, }); - }); - expect(messages).toContainEqual( - expect.objectContaining({ type: "auth_result", success: true, permission: "write" }), - ); - expect(messages).toContainEqual( - expect.objectContaining({ type: "snapshot", value, version: 1 }), - ); - const entityAddress = makeDurableEntityAddress( - "mimic-document", - `${collectionId}:${documentId}`, - ); - const attached = await Effect.runPromise( - entities.run(entityAddress, (entity) => entity.sessions.list), - ); - expect(attached).toHaveLength(1); - - const closed = new Promise((resolve) => socket.once("close", () => resolve())); - socket.close(1000, "done"); - await closed; - let remaining = attached; - for (let attempt = 0; attempt < 20 && remaining.length > 0; attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, 10)); - remaining = await Effect.runPromise( - entities.run(entityAddress, (entity) => entity.sessions.list), - ); - } - expect(remaining).toHaveLength(0); - } finally { - if (databaseId && collectionId) { - const cleanupDatabaseId = databaseId; - const cleanupCollectionId = collectionId; - await runStandalone( - Effect.gen(function* () { + yield* Effect.callback((resume) => { + server.once("error", (error) => + resume(Effect.fail(new MimicNodeTestError({ message: causeMessage(error) }))), + ); + server.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + + const suffix = generateId("test"); + const documentId = `ws-${suffix.slice(0, 20)}`; + let databaseId: string | undefined; + let collectionId: string | undefined; + + const cleanup = Effect.gen(function* () { + if (databaseId && collectionId) { + const cleanupDatabaseId = databaseId; + const cleanupCollectionId = collectionId; yield* host.deleteDocument(cleanupCollectionId, documentId); yield* host.deleteCollection(cleanupCollectionId); yield* host.deleteDatabase(cleanupDatabaseId); - }), - ); - } - closeWebSockets(); - await new Promise((resolve) => server.close(() => resolve())); - await runtime.dispose(); - } - }); + } + closeWebSockets(); + yield* Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }); + yield* Effect.promise(() => runtime.dispose()); + }).pipe(Effect.orDie); + + const body = Effect.gen(function* () { + const created = yield* Effect.gen(function* () { + const database = yield* host.createDatabase(`ws-${suffix}`, "integration"); + const collection = yield* host.createCollection(database.id, "documents", schema); + const document = yield* host.createDocument(collection.id, documentId, value); + const auth = yield* host.createDocumentAuthToken( + collection.id, + document.id, + "write", + [], + 60, + ); + return { database, collection, document, auth }; + }); + databaseId = created.database.id; + collectionId = created.collection.id; + const address = server.address(); + if (address === null || typeof address === "string") { + return yield* Effect.fail( + new MimicNodeTestError({ message: "HTTP server did not expose a TCP port" }), + ); + } + const socket = new WebSocket( + `ws://127.0.0.1:${address.port}/ws/v1/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, + ); + const messages = yield* Effect.callback, MimicNodeTestError>( + (resume) => { + const received: unknown[] = []; + socket.once("error", (error) => + resume(Effect.fail(new MimicNodeTestError({ message: causeMessage(error) }))), + ); + socket.once("open", () => + socket.send(encodeJson({ type: "auth", token: created.auth.token })), + ); + socket.on("message", (data) => { + const message = decodeJson(data.toString()); + received.push(message); + if (messageType(message) === "snapshot") resume(Effect.succeed(received)); + }); + }, + ).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail( + new MimicNodeTestError({ message: "timed out waiting for snapshot" }), + ), + }), + ); + expect(messages).toContainEqual( + expect.objectContaining({ type: "auth_result", success: true, permission: "write" }), + ); + expect(messages).toContainEqual( + expect.objectContaining({ type: "snapshot", value, version: 1 }), + ); + const entityAddress = makeDurableEntityAddress( + "mimic-document", + `${collectionId}:${documentId}`, + ); + const attached = yield* entities.run(entityAddress, (entity) => entity.sessions.list); + expect(attached).toHaveLength(1); + + yield* Effect.callback((resume) => { + socket.once("close", () => resume(Effect.void)); + socket.close(1000, "done"); + }); + let remaining = attached; + for (let attempt = 0; attempt < 20 && remaining.length > 0; attempt += 1) { + yield* Effect.sleep("10 millis"); + remaining = yield* entities.run(entityAddress, (entity) => entity.sessions.list); + } + expect(remaining).toHaveLength(0); + }); + + yield* body.pipe(Effect.ensuring(cleanup)); + }), + )); }); diff --git a/apps/backend/tests/MimicNodeWebSocket.test.ts b/apps/backend/tests/MimicNodeWebSocket.test.ts index 7d6fed53a..7fe3e901a 100644 --- a/apps/backend/tests/MimicNodeWebSocket.test.ts +++ b/apps/backend/tests/MimicNodeWebSocket.test.ts @@ -1,12 +1,12 @@ import { createServer, type Server } from "node:http"; -import type { AddressInfo } from "node:net"; +import { constant } from "@voidhash/lib/lang"; +import { objectValue } from "@voidhash/mimic-core"; import type { HostService } from "@voidhash/mimic-db/app/hostService"; -import type { SessionAttachment } from "@voidhash/mimic-db/ws/document-session"; import { makeDurableEntityAddress } from "@voidhash/platform/DurableEntity"; import { makeMemoryDurableEntityHost } from "@voidhash/platform-selfhost/MemoryDurableEntity"; -import { Effect } from "effect"; -import { afterEach, describe, expect, it } from "vitest"; +import { Data, Effect, Option, Schema } from "effect"; +import { describe, expect, it } from "vitest"; import WebSocket from "ws"; import { installMimicNodeWebSocketServer } from "../src/mimic/MimicNodeWebSocket.ts"; @@ -14,93 +14,167 @@ import { installMimicNodeWebSocketServer } from "../src/mimic/MimicNodeWebSocket const collectionId = "collection-1"; const documentId = "document-1"; +class TestServerAddressError extends Data.TaggedError("TestServerAddressError")<{ + readonly message: string; +}> {} + +const notImplemented = (name: string) => () => + Effect.die(new Error(`HostService.${name} is not reachable from the document socket protocol`)); + /** * The slice of the host the document socket protocol touches while a client - * authenticates. Everything else stays unimplemented on purpose: reaching for - * it in this test would mean the socket path grew a dependency it should not - * have. + * authenticates. Everything else dies on purpose: reaching for it in this test + * would mean the socket path grew a dependency it should not have. */ -const stubHost = { +const stubHost: HostService = { + authenticateBasic: notImplemented("authenticateBasic"), authenticateDocumentToken: () => - Effect.succeed({ tokenId: "token-1", permission: "write" as const }), + Effect.succeed({ tokenId: "token-1", permission: constant("write") }), + createDatabase: notImplemented("createDatabase"), + listDatabases: notImplemented("listDatabases"), + deleteDatabase: notImplemented("deleteDatabase"), + createCollection: notImplemented("createCollection"), + listCollections: notImplemented("listCollections"), + deleteCollection: notImplemented("deleteCollection"), + createUser: notImplemented("createUser"), + listUsers: notImplemented("listUsers"), + deleteUser: notImplemented("deleteUser"), + grantPermission: notImplemented("grantPermission"), + revokePermission: notImplemented("revokePermission"), + listGrants: notImplemented("listGrants"), + createDocumentAuthToken: notImplemented("createDocumentAuthToken"), + createDocument: notImplemented("createDocument"), getDocument: () => Effect.succeed({ - value: { kind: "object" as const, fields: {} }, + collectionId, + id: documentId, + value: objectValue(), version: 1, }), + listDocuments: notImplemented("listDocuments"), + deleteDocument: notImplemented("deleteDocument"), + submitTransaction: notImplemented("submitTransaction"), + attachConnection: notImplemented("attachConnection"), + heartbeatConnection: notImplemented("heartbeatConnection"), + getConnectionDocument: notImplemented("getConnectionDocument"), + submitConnectionTransaction: notImplemented("submitConnectionTransaction"), + detachConnection: notImplemented("detachConnection"), getPresenceSnapshot: () => Effect.succeed({ presences: {} }), setPresence: () => Effect.void, removePresence: () => Effect.void, -} as unknown as HostService; + ensureDatabasePermission: notImplemented("ensureDatabasePermission"), + databaseIdForCollection: notImplemented("databaseIdForCollection"), +}; + +const AuthMessage = Schema.Struct({ + type: Schema.Literal("auth"), + token: Schema.String, +}); +const encodeAuthMessage = Schema.encodeSync(Schema.fromJsonString(AuthMessage)); + +const ServerMessage = Schema.Struct({ type: Schema.optional(Schema.String) }); +const decodeServerMessage = Schema.decodeUnknownOption(Schema.fromJsonString(ServerMessage)); -const cleanups: Array<() => Promise | void> = []; +const utf8 = new TextDecoder(); -afterEach(async () => { - for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +/** + * `ws` hands frame payloads over as a Buffer, an ArrayBuffer, or (when + * `fragments` are kept) an array of Buffers; decode each shape explicitly + * rather than relying on default stringification. + */ +const rawDataToString = (data: WebSocket.RawData): string => { + if (Array.isArray(data)) { + return data.map((chunk) => utf8.decode(chunk)).join(""); + } + return utf8.decode(data); +}; + +const SessionAttachmentShape = Schema.Struct({ + authenticated: Schema.Boolean, + permission: Schema.optional(Schema.Literals(["read", "write"])), }); +const decodeSessionAttachment = Schema.decodeUnknownSync(SessionAttachmentShape); -const listen = (server: Server): Promise => - new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.off("error", reject); - resolve((server.address() as AddressInfo).port); +/** Binds the server to an ephemeral loopback port and returns it. */ +const listen = (server: Server) => + Effect.gen(function* () { + yield* Effect.callback((resume) => { + const onError = (error: Error) => resume(Effect.fail(error)); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resume(Effect.void); + }); }); + const address = server.address(); + if (address === null || typeof address === "string") { + return yield* new TestServerAddressError({ + message: "Test server did not expose a TCP address", + }); + } + return address.port; }); describe("mimic Node WebSocket sessions", () => { - it("keeps the entity session attachment in step with authentication", async () => { - const entities = makeMemoryDurableEntityHost(); - const server = createServer(); - const close = installMimicNodeWebSocketServer(server, stubHost, entities, { - control: { listDueAlarms: () => Effect.succeed([]) }, - debounceMs: 15_000, - pollIntervalMs: 60_000, - publish: () => Effect.void, - }); - cleanups.push(() => { - close(); - return new Promise((resolve) => server.close(() => resolve())); - }); - const port = await listen(server); - - const socket = new WebSocket( - `ws://127.0.0.1:${port}/ws/v1/databases/database-1/collections/${collectionId}/documents/${documentId}`, - ); - cleanups.push(() => void socket.close()); - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("timed out waiting for a snapshot")), 5_000); - socket.once("error", reject); - socket.once("open", () => - socket.send(JSON.stringify({ type: "auth", token: "token-1" })), - ); - socket.on("message", (data) => { - const message = JSON.parse(data.toString()) as { readonly type?: string }; - if (message.type === "snapshot") { - clearTimeout(timeout); - resolve(); - } - }); - }); + it("keeps the entity session attachment in step with authentication", () => + Effect.runPromise( + Effect.gen(function* () { + const entities = makeMemoryDurableEntityHost(); + const server = createServer(); + const close = installMimicNodeWebSocketServer(server, stubHost, entities, { + control: { listDueAlarms: () => Effect.succeed([]) }, + debounceMs: 15_000, + pollIntervalMs: 60_000, + publish: () => Effect.void, + }); + yield* Effect.addFinalizer(() => + Effect.callback((resume) => { + close(); + server.close(() => resume(Effect.void)); + }), + ); + const port = yield* listen(server); + + const socket = new WebSocket( + `ws://127.0.0.1:${port}/ws/v1/databases/database-1/collections/${collectionId}/documents/${documentId}`, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => socket.close())); + yield* Effect.callback((resume) => { + socket.once("error", (error) => resume(Effect.fail(error))); + socket.once("open", () => + socket.send(encodeAuthMessage({ type: "auth", token: "token-1" })), + ); + socket.on("message", (data) => { + const message = decodeServerMessage(rawDataToString(data)); + if (Option.isSome(message) && message.value.type === "snapshot") { + resume(Effect.void); + } + }); + }).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.die(new Error("timed out waiting for a snapshot")), + }), + ); - const address = makeDurableEntityAddress( - "mimic-document", - `${collectionId}:${documentId}`, - ); - const attachments = await Effect.runPromise( - entities.run(address, (entity) => - entity.sessions.list.pipe( - Effect.flatMap((sessions) => - Effect.forEach(sessions, (session) => session.getAttachment), + const address = makeDurableEntityAddress( + "mimic-document", + `${collectionId}:${documentId}`, + ); + const attachments = yield* entities.run(address, (entity) => + entity.sessions.list.pipe( + Effect.flatMap((sessions) => + Effect.forEach(sessions, (session) => session.getAttachment), + ), ), - ), - ), - ); - - // Host-side broadcasts filter on this exact flag, so a stale attachment - // here means every authenticated browser socket is silently skipped. - expect(attachments).toHaveLength(1); - expect((attachments[0] as SessionAttachment).authenticated).toBe(true); - expect((attachments[0] as SessionAttachment).permission).toBe("write"); - }); + ); + + // Host-side broadcasts filter on this exact flag, so a stale attachment + // here means every authenticated browser socket is silently skipped. + expect(attachments).toHaveLength(1); + const attachment = decodeSessionAttachment(attachments[0]); + expect(attachment.authenticated).toBe(true); + expect(attachment.permission).toBe("write"); + }).pipe(Effect.scoped), + )); }); diff --git a/apps/backend/tests/PaywallRelease.integration.test.ts b/apps/backend/tests/PaywallRelease.integration.test.ts index 0a130b3cf..5c9135502 100644 --- a/apps/backend/tests/PaywallRelease.integration.test.ts +++ b/apps/backend/tests/PaywallRelease.integration.test.ts @@ -8,14 +8,14 @@ import { import type { AnyAuthSession } from "@voidhash/core/domain/auth/Auth"; import { AuthSession } from "@voidhash/core/domain/auth/Auth"; import { PaywallAssetConfig } from "@voidhash/core/services/paywallLocations/PaywallAssetConfig"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, ReleaseStatus, eq, paywallReleases, paywalls } from "@voidhash/db"; -import { Effect, Layer } from "effect"; +import { DateTime, Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -const makeSession = (projectId: string, userId: string): AnyAuthSession => { - const now = new Date(); +const makeSession = (projectId: string, userId: string, now: Date): AnyAuthSession => { return { cookie: null, method: "user", @@ -47,112 +47,112 @@ const makeSession = (projectId: string, userId: string): AnyAuthSession => { }; describe("self-host paywall releases", () => { - it("creates, publishes, and advances a visual paywall release", async () => { - const config = getSelfhostRuntimeConfig(); - const suffix = crypto.randomUUID(); - const paywallId = `paywall_${suffix}`; - const projectId = `project_${suffix}`; - const userId = `user_${suffix}`; - const objects = new Map(); - const database = Db.layer(config.database); - const dependencies = Layer.mergeAll( - database, - AuditLogPort.noop, - Layer.succeed(MimicHost, { - closePaywallConnection: () => Effect.die("unused"), - createPaywallEditToken: () => Effect.die("unused"), - ensurePaywallDocument: () => Effect.void, - getConnectedPaywallDocument: () => Effect.die("unused"), - getPaywallDocument: () => Effect.die("unused"), - getPaywallSnapshot: () => Effect.succeed({ id: "root", type: "root" }), - heartbeatPaywallConnection: () => Effect.die("unused"), - openPaywallConnection: () => Effect.die("unused"), - submitConnectedPaywallTransaction: () => Effect.die("unused"), - submitPaywallTransaction: () => Effect.die("unused"), - }), - Layer.succeed(PaywallArtifactStore, { - bucketName: "selfhost-release-test", - getObject: (key) => - Effect.sync(() => { - const body = objects.get(key); - return body === undefined ? null : { body, contentType: "text/html; charset=utf-8" }; + it("creates, publishes, and advances a visual paywall release", () => + Effect.runPromise( + Effect.gen(function* () { + const config = getSelfhostRuntimeConfig(); + const suffix = generateId("test"); + const paywallId = `paywall_${suffix}`; + const projectId = `project_${suffix}`; + const userId = `user_${suffix}`; + const now = yield* DateTime.nowAsDate; + const objects = new Map(); + const database = Db.layer(config.database); + const dependencies = Layer.mergeAll( + database, + AuditLogPort.noop, + Layer.succeed(MimicHost, { + closePaywallConnection: () => Effect.die("unused"), + createPaywallEditToken: () => Effect.die("unused"), + ensurePaywallDocument: () => Effect.void, + getConnectedPaywallDocument: () => Effect.die("unused"), + getPaywallDocument: () => Effect.die("unused"), + getPaywallSnapshot: () => Effect.succeed({ id: "root", type: "root" }), + heartbeatPaywallConnection: () => Effect.die("unused"), + openPaywallConnection: () => Effect.die("unused"), + submitConnectedPaywallTransaction: () => Effect.die("unused"), + submitPaywallTransaction: () => Effect.die("unused"), }), - head: (key) => - Effect.succeed(objects.has(key) ? { size: objects.get(key)?.length ?? 0 } : null), - putObject: ({ body, key }) => - Effect.sync(() => { - objects.set(key, body); + Layer.succeed(PaywallArtifactStore, { + bucketName: "selfhost-release-test", + getObject: (key) => + Effect.sync(() => { + const body = objects.get(key); + if (body === undefined) return null; + return { body, contentType: "text/html; charset=utf-8" }; + }), + head: (key) => + Effect.sync(() => { + if (!objects.has(key)) return null; + return { size: objects.get(key)?.length ?? 0 }; + }), + putObject: ({ body, key }) => + Effect.sync(() => { + objects.set(key, body); + }), }), - }), - Layer.succeed(PaywallAssetConfig, { - cdnUrl: "http://localhost:5001", - publicBaseUrl: "http://localhost:5001", - }), - Layer.succeed(SnapshotHtmlRenderer, { - render: ({ metadata }) => - Effect.succeed(`release ${metadata.version}`), - }), - ); - const releaseLayer = PaywallReleaseService.layer.pipe(Layer.provide(dependencies)); + Layer.succeed(PaywallAssetConfig, { + cdnUrl: "http://localhost:5001", + publicBaseUrl: "http://localhost:5001", + }), + Layer.succeed(SnapshotHtmlRenderer, { + render: ({ metadata }) => + Effect.succeed(`release ${metadata.version}`), + }), + ); + const releaseLayer = PaywallReleaseService.layer.pipe(Layer.provide(dependencies)); - try { - await Effect.runPromise( - Effect.gen(function* () { + const cleanup = Effect.gen(function* () { const db = yield* Db; - yield* db.insert(paywalls).values({ - id: paywallId, - name: "Self-host release", - projectId, - slug: `selfhost-release-${suffix}`, - }); - }).pipe(Effect.provide(database)), - ); + yield* db.delete(paywallReleases).where(eq(paywallReleases.paywallId, paywallId)); + yield* db.delete(paywalls).where(eq(paywalls.id, paywallId)); + }).pipe(Effect.provide(database), Effect.orDie); - const result = await Effect.runPromise( - Effect.gen(function* () { - const releases = yield* PaywallReleaseService; - const firstDraft = yield* releases.createRelease(paywallId); - const draft = yield* releases.getDraftRelease(paywallId); - const firstPublished = yield* releases.publishRelease(firstDraft.releaseId); - const secondDraft = yield* releases.createRelease(paywallId); - const secondPublished = yield* releases.publishRelease(secondDraft.releaseId); - return { draft, firstDraft, firstPublished, secondDraft, secondPublished }; - }).pipe( - Effect.provide(releaseLayer), - Effect.provideService(AuthSession, makeSession(projectId, userId)), - ), - ); + yield* Effect.gen(function* () { + yield* Effect.gen(function* () { + const db = yield* Db; + yield* db.insert(paywalls).values({ + id: paywallId, + name: "Self-host release", + projectId, + slug: `selfhost-release-${suffix}`, + }); + }).pipe(Effect.provide(database)); - const rows = await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - return yield* db.query.paywallReleases.findMany({ - orderBy: { version: "asc" }, - where: { paywallId }, - }); - }).pipe(Effect.provide(database)), - ); + const result = yield* Effect.gen(function* () { + const releases = yield* PaywallReleaseService; + const firstDraft = yield* releases.createRelease(paywallId); + const draft = yield* releases.getDraftRelease(paywallId); + const firstPublished = yield* releases.publishRelease(firstDraft.releaseId); + const secondDraft = yield* releases.createRelease(paywallId); + const secondPublished = yield* releases.publishRelease(secondDraft.releaseId); + return { draft, firstDraft, firstPublished, secondDraft, secondPublished }; + }).pipe( + Effect.provide(releaseLayer), + Effect.provideService(AuthSession, makeSession(projectId, userId, now)), + ); - expect(result.draft?.releaseId).toBe(result.firstDraft.releaseId); - expect(result.firstPublished.version).toBe(1); - expect(result.secondDraft.version).toBe(2); - expect(result.secondPublished.version).toBe(2); - expect(objects.size).toBe(2); - expect(rows.filter((row) => row.status === ReleaseStatus.released)).toHaveLength(2); - expect( - rows.find((row) => row.version === 1 && row.status === ReleaseStatus.released)?.isActive, - ).toBe(false); - expect( - rows.find((row) => row.version === 2 && row.status === ReleaseStatus.released)?.isActive, - ).toBe(true); - } finally { - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - yield* db.delete(paywallReleases).where(eq(paywallReleases.paywallId, paywallId)); - yield* db.delete(paywalls).where(eq(paywalls.id, paywallId)); - }).pipe(Effect.provide(database)), - ); - } - }); + const rows = yield* Effect.gen(function* () { + const db = yield* Db; + return yield* db.query.paywallReleases.findMany({ + orderBy: { version: "asc" }, + where: { paywallId }, + }); + }).pipe(Effect.provide(database)); + + expect(result.draft?.releaseId).toBe(result.firstDraft.releaseId); + expect(result.firstPublished.version).toBe(1); + expect(result.secondDraft.version).toBe(2); + expect(result.secondPublished.version).toBe(2); + expect(objects.size).toBe(2); + expect(rows.filter((row) => row.status === ReleaseStatus.released)).toHaveLength(2); + expect( + rows.find((row) => row.version === 1 && row.status === ReleaseStatus.released)?.isActive, + ).toBe(false); + expect( + rows.find((row) => row.version === 2 && row.status === ReleaseStatus.released)?.isActive, + ).toBe(true); + }).pipe(Effect.ensuring(cleanup)); + }), + )); }); diff --git a/apps/backend/tests/Push.integration.test.ts b/apps/backend/tests/Push.integration.test.ts index e60eb2a02..647808257 100644 --- a/apps/backend/tests/Push.integration.test.ts +++ b/apps/backend/tests/Push.integration.test.ts @@ -1,6 +1,7 @@ import { PushDeliveryDispatch } from "@voidhash/core/services/notifications/PushDeliveryDispatch"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, sql } from "@voidhash/db"; -import { Context, Effect, Layer } from "effect"; +import { Clock, Context, Effect, Layer, Predicate } from "effect"; import { describe, expect, it } from "vitest"; import { makeSelfhostAnalyticsRuntimeLive } from "../src/backend/Analytics.ts"; @@ -10,54 +11,61 @@ import { } from "../src/backend/Push.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -describe("self-host push-delivery queue", () => { - it("dispatches and acknowledges a delivery pointer through the consumer", async () => { - const config = getSelfhostRuntimeConfig(); - const deliveryId = `pushDelivery_${crypto.randomUUID()}`; +/** Reads the `total` column off an untyped SQL row. */ +const totalOf = (row: unknown): number => { + if (!Predicate.isObject(row)) return 0; + return Number(row["total"] ?? 0); +}; - const remaining = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - // The queue rows live in the platform database, which is a different - // connection from the application tables the consumer reads. - const platformContext = yield* Layer.build(Db.layer(config.platformDatabase)); - const db = Context.get(platformContext, Db); - // The cluster queue driver hands the store a JSON string, which the - // store then JSON-encodes into `element`, so the body is doubly - // encoded: unwrap the outer JSON scalar before reading its fields. - yield* db.execute(sql` - DELETE FROM effect_queue - WHERE (element::jsonb #>> '{}')::jsonb ->> 'pushNotificationDeliveryId' = ${deliveryId} - `); - const dispatchContext = yield* Layer.build(SelfhostPushDeliveryDispatchLive); - const dispatch = Context.get(dispatchContext, PushDeliveryDispatch); - yield* Effect.forkScoped(runSelfhostPushDeliveryConsumers(config)); - yield* dispatch.dispatch([ - { - projectId: "project_push_integration", - provider: "fcm", - pushNotificationDeliveryId: deliveryId, - pushNotificationSendId: "pushSend_integration", - }, - ]); +describe("self-host push-delivery queue", () => { + it("dispatches and acknowledges a delivery pointer through the consumer", () => + Effect.runPromise( + Effect.gen(function* () { + const config = getSelfhostRuntimeConfig(); + const deliveryId = `pushDelivery_${generateId("test")}`; - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - const rows = yield* db.execute(sql` - SELECT COUNT(*)::integer AS total - FROM effect_queue - WHERE completed = FALSE - AND (element::jsonb #>> '{}')::jsonb ->> 'pushNotificationDeliveryId' = ${deliveryId} + const remaining = yield* Effect.scoped( + Effect.gen(function* () { + // The queue rows live in the platform database, which is a different + // connection from the application tables the consumer reads. + const platformContext = yield* Layer.build(Db.layer(config.platformDatabase)); + const db = Context.get(platformContext, Db); + // The cluster queue driver hands the store a JSON string, which the + // store then JSON-encodes into `element`, so the body is doubly + // encoded: unwrap the outer JSON scalar before reading its fields. + yield* db.execute(sql` + DELETE FROM effect_queue + WHERE (element::jsonb #>> '{}')::jsonb ->> 'pushNotificationDeliveryId' = ${deliveryId} `); - const total = Number((rows[0] as { readonly total?: number } | undefined)?.total ?? 0); - if (total === 0) return total; - yield* Effect.sleep("25 millis"); - } - return 1; - }).pipe(Effect.provide(makeSelfhostAnalyticsRuntimeLive(config))), - ), - ); + const dispatchContext = yield* Layer.build(SelfhostPushDeliveryDispatchLive); + const dispatch = Context.get(dispatchContext, PushDeliveryDispatch); + yield* Effect.forkScoped(runSelfhostPushDeliveryConsumers(config)); + yield* dispatch.dispatch([ + { + projectId: "project_push_integration", + provider: "fcm", + pushNotificationDeliveryId: deliveryId, + pushNotificationSendId: "pushSend_integration", + }, + ]); + + const deadline = (yield* Clock.currentTimeMillis) + 10_000; + while ((yield* Clock.currentTimeMillis) < deadline) { + const rows = yield* db.execute(sql` + SELECT COUNT(*)::integer AS total + FROM effect_queue + WHERE completed = FALSE + AND (element::jsonb #>> '{}')::jsonb ->> 'pushNotificationDeliveryId' = ${deliveryId} + `); + const total = totalOf(rows[0]); + if (total === 0) return total; + yield* Effect.sleep("25 millis"); + } + return 1; + }).pipe(Effect.provide(makeSelfhostAnalyticsRuntimeLive(config))), + ); - expect(remaining).toBe(0); - }); + expect(remaining).toBe(0); + }), + )); }); diff --git a/apps/backend/tests/SecurityConfig.test.ts b/apps/backend/tests/SecurityConfig.test.ts index 8476f862d..921349899 100644 --- a/apps/backend/tests/SecurityConfig.test.ts +++ b/apps/backend/tests/SecurityConfig.test.ts @@ -1,8 +1,9 @@ +import { constant } from "@voidhash/lib/lang"; import { afterEach, describe, expect, it, vi } from "vitest"; import { validateSelfhostSecurityConfig } from "../src/config.ts"; -const validProductionEnvironment = { +const validProductionEnvironment = constant({ CLICKHOUSE_URL: "", DATABASE_PASSWORD: "database-secret", MIMIC_PUBLIC_BASE_URL: "https://mimic.example.test", @@ -15,7 +16,7 @@ const validProductionEnvironment = { VOIDHASH_AUTH_SECRET: "session-signing-secret-with-entropy", VOIDHASH_ROOT_PASSWORD: "root-secret-with-sufficient-entropy", VOIDHASH_ROOT_USERNAME: "operator", -} as const; +}); const stubEnvironment = (environment: Record) => { for (const [name, value] of Object.entries(environment)) { diff --git a/apps/backend/tests/StandaloneAuth.integration.test.ts b/apps/backend/tests/StandaloneAuth.integration.test.ts index 29f8d2ff5..e5441cc75 100644 --- a/apps/backend/tests/StandaloneAuth.integration.test.ts +++ b/apps/backend/tests/StandaloneAuth.integration.test.ts @@ -7,9 +7,9 @@ import { signStandaloneAuthToken, } from "@voidhash/core/utils/crypto/standalone-auth-token"; import { Db, eq, sql, user } from "@voidhash/db"; -import { Context, Effect, Layer, Redacted } from "effect"; +import { Context, DateTime, Effect, Exit, Layer, Redacted } from "effect"; import * as HttpHeaders from "effect/unstable/http/Headers"; -import { afterAll, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { makeSelfhostAuthLayers } from "../src/backend/Backend.ts"; import { getSelfhostDatabaseConfig } from "../src/config.ts"; @@ -47,84 +47,104 @@ const resolve = (headers: Record) => Effect.provide(LocalUserSessionService.layer), Effect.provide(database), ); - }).pipe(Effect.scoped, Effect.runPromise); + }).pipe(Effect.scoped); + +const optionalName = (name?: string): { readonly name?: string } => { + if (!name) return {}; + return { name }; +}; const token = (email: string, name?: string) => - Effect.runPromise(signStandaloneAuthToken({ email, secret, ...(name ? { name } : {}) })); + signStandaloneAuthToken({ email, secret, ...optionalName(name) }); + +/** Runs a test body and always drops the rows the suite provisions. */ +const runTest = (body: Effect.Effect) => + Effect.runPromise(body.pipe(Effect.ensuring(cleanup.pipe(Effect.orDie)))); describe("standalone identity provider against Postgres", () => { - afterAll(async () => { - await Effect.runPromise(cleanup); - }); - - it("creates the root user row on first cookie authentication", async () => { - await Effect.runPromise(cleanup); - const session = await resolve({ - cookie: `${STANDALONE_AUTH_COOKIE_NAME}=${await token(rootEmail, "Root Operator")}`, - }); - - expect(session.method).toBe("user"); - expect(session.user?.email).toBe(rootEmail); - expect(session.user?.workosUserId).toBe(STANDALONE_ROOT_SUBJECT); - expect(session.user?.name).toBe("Root Operator"); - }); - - it("resolves the same single user through the bearer path", async () => { - const bearer = await token(rootEmail, "Root Operator"); - - const first = await resolve({ authorization: `Bearer ${bearer}` }); - const second = await resolve({ cookie: `${STANDALONE_AUTH_COOKIE_NAME}=${bearer}` }); - - expect(first.user?.id).toBe(second.user?.id); - expect(first.user?.email).toBe(rootEmail); - }); - - it("rejects a token signed with a different secret", async () => { - const forged = await Effect.runPromise( - signStandaloneAuthToken({ email: rootEmail, secret: "not-the-secret" }), - ); + it("creates the root user row on first cookie authentication", () => + runTest( + Effect.gen(function* () { + yield* cleanup; + const session = yield* resolve({ + cookie: `${STANDALONE_AUTH_COOKIE_NAME}=${yield* token(rootEmail, "Root Operator")}`, + }); - await expect(resolve({ authorization: `Bearer ${forged}` })).rejects.toBeDefined(); - }); + expect(session.method).toBe("user"); + expect(session.user?.email).toBe(rootEmail); + expect(session.user?.workosUserId).toBe(STANDALONE_ROOT_SUBJECT); + expect(session.user?.name).toBe("Root Operator"); + }), + )); - it("rejects a request with no credentials", async () => { - await expect(resolve({})).rejects.toBeDefined(); - }); + it("resolves the same single user through the bearer path", () => + runTest( + Effect.gen(function* () { + const bearer = yield* token(rootEmail, "Root Operator"); - it("adopts an existing row for the same email instead of creating a second user", async () => { - await Effect.runPromise(cleanup); - await Effect.runPromise( + const first = yield* resolve({ authorization: `Bearer ${bearer}` }); + const second = yield* resolve({ cookie: `${STANDALONE_AUTH_COOKIE_NAME}=${bearer}` }); + + expect(first.user?.id).toBe(second.user?.id); + expect(first.user?.email).toBe(rootEmail); + }), + )); + + it("rejects a token signed with a different secret", () => + runTest( Effect.gen(function* () { - const db = yield* Db; - yield* db.insert(user).values({ - banned: false, - banExpires: null, - banReason: null, - createdAt: new Date(), - customImageUrl: null, + const forged = yield* signStandaloneAuthToken({ email: rootEmail, - emailVerified: true, - id: "user_standaloneadopt00000000", - image: null, - name: "Previously Provisioned", - role: null, - updatedAt: new Date(), - workosUserId: "user_external_previous", + secret: "not-the-secret", }); - }).pipe(Effect.provide(database), Effect.scoped), - ); - const session = await resolve({ authorization: `Bearer ${await token(rootEmail)}` }); + const exit = yield* Effect.exit(resolve({ authorization: `Bearer ${forged}` })); + expect(Exit.isFailure(exit)).toBe(true); + }), + )); - expect(session.user?.id).toBe("user_standaloneadopt00000000"); - expect(session.user?.workosUserId).toBe(STANDALONE_ROOT_SUBJECT); + it("rejects a request with no credentials", () => + runTest( + Effect.gen(function* () { + const exit = yield* Effect.exit(resolve({})); + expect(Exit.isFailure(exit)).toBe(true); + }), + )); - const rows = await Effect.runPromise( + it("adopts an existing row for the same email instead of creating a second user", () => + runTest( Effect.gen(function* () { - const db = yield* Db; - return yield* db.select().from(user).where(eq(user.email, rootEmail)); - }).pipe(Effect.provide(database), Effect.scoped), - ); - expect(rows).toHaveLength(1); - }); + yield* cleanup; + const now = yield* DateTime.nowAsDate; + yield* Effect.gen(function* () { + const db = yield* Db; + yield* db.insert(user).values({ + banned: false, + banExpires: null, + banReason: null, + createdAt: now, + customImageUrl: null, + email: rootEmail, + emailVerified: true, + id: "user_standaloneadopt00000000", + image: null, + name: "Previously Provisioned", + role: null, + updatedAt: now, + workosUserId: "user_external_previous", + }); + }).pipe(Effect.provide(database), Effect.scoped); + + const session = yield* resolve({ authorization: `Bearer ${yield* token(rootEmail)}` }); + + expect(session.user?.id).toBe("user_standaloneadopt00000000"); + expect(session.user?.workosUserId).toBe(STANDALONE_ROOT_SUBJECT); + + const rows = yield* Effect.gen(function* () { + const db = yield* Db; + return yield* db.select().from(user).where(eq(user.email, rootEmail)); + }).pipe(Effect.provide(database), Effect.scoped); + expect(rows).toHaveLength(1); + }), + )); }); diff --git a/apps/backend/tests/StandaloneOrgDirectory.integration.test.ts b/apps/backend/tests/StandaloneOrgDirectory.integration.test.ts index dc8de8c22..faa28184e 100644 --- a/apps/backend/tests/StandaloneOrgDirectory.integration.test.ts +++ b/apps/backend/tests/StandaloneOrgDirectory.integration.test.ts @@ -2,7 +2,7 @@ import { StandaloneOrgDirectoryLive } from "@voidhash/core/services/organization import { OrgDirectoryPort } from "@voidhash/core/services/organizations/OrgDirectoryPort"; import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, eq, member, organization, user } from "@voidhash/db"; -import { Effect } from "effect"; +import { DateTime, Effect } from "effect"; import { afterAll, describe, expect, it } from "vitest"; import { getSelfhostDatabaseConfig } from "../src/config.ts"; @@ -24,28 +24,29 @@ const withServices = (effect: Effect.Effect) ); describe("local organization directory", () => { - afterAll(async () => { - await withServices( + afterAll(() => + withServices( Effect.gen(function* () { const db = yield* Db; yield* db.delete(member).where(eq(member.id, memberId)); yield* db.delete(organization).where(eq(organization.id, orgId)); yield* db.delete(user).where(eq(user.id, userId)); }), - ); - }); + ), + ); - it("synthesizes provider ids that satisfy the NOT NULL workos columns", async () => { - await withServices( + it("synthesizes provider ids that satisfy the NOT NULL workos columns", () => + withServices( Effect.gen(function* () { const port = yield* OrgDirectoryPort; const db = yield* Db; + const now = yield* DateTime.nowAsDate; yield* db.insert(user).values({ banned: false, banExpires: null, banReason: null, - createdAt: new Date(), + createdAt: now, customImageUrl: null, email, emailVerified: true, @@ -53,7 +54,7 @@ describe("local organization directory", () => { image: null, name: "Directory Dev", role: null, - updatedAt: new Date(), + updatedAt: now, workosUserId: `local_${"a".repeat(24)}`, }); @@ -76,7 +77,7 @@ describe("local organization directory", () => { // The real INSERTs OrganizationService performs — proof the synthesized // ids actually satisfy the constraints. yield* db.insert(organization).values({ - createdAt: new Date(), + createdAt: now, id: orgId, logo: null, metadata: null, @@ -85,7 +86,7 @@ describe("local organization directory", () => { workosOrganizationId: createdOrg.id, }); yield* db.insert(member).values({ - createdAt: new Date(), + createdAt: now, id: memberId, organizationId: orgId, role: "admin", @@ -93,11 +94,10 @@ describe("local organization directory", () => { workosMembershipId: membership.id, }); }), - ); - }); + )); - it("reads users and memberships back out of the local tables", async () => { - await withServices( + it("reads users and memberships back out of the local tables", () => + withServices( Effect.gen(function* () { const port = yield* OrgDirectoryPort; @@ -115,15 +115,13 @@ describe("local organization directory", () => { const org = yield* port.getOrganizationByExternalId(orgId); expect(org?.name).toBe("Directory Org"); }), - ); - }); + )); - it("returns null for an unknown email", async () => { - await withServices( + it("returns null for an unknown email", () => + withServices( Effect.gen(function* () { const port = yield* OrgDirectoryPort; expect(yield* port.findUserByEmail("nobody@integration.test")).toBeNull(); }), - ); - }); + )); }); diff --git a/apps/backend/tests/ThumbnailQueue.integration.test.ts b/apps/backend/tests/ThumbnailQueue.integration.test.ts index 50e49c15a..a19c079ad 100644 --- a/apps/backend/tests/ThumbnailQueue.integration.test.ts +++ b/apps/backend/tests/ThumbnailQueue.integration.test.ts @@ -1,6 +1,7 @@ import { PaywallThumbnailService } from "@voidhash/core/services/paywallThumbnails/PaywallThumbnailService"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, sql } from "@voidhash/db"; -import { Effect } from "effect"; +import { Clock, Effect } from "effect"; import { describe, expect, it } from "vitest"; import { makeSelfhostAnalyticsRuntimeLive } from "../src/backend/Analytics.ts"; @@ -12,14 +13,26 @@ import { } from "../src/mimic/MimicDocumentIdleQueue.ts"; describe("self-host thumbnail queue", () => { - it("delivers and acknowledges an idle-document revision", async () => { - const config = getSelfhostRuntimeConfig(); - const documentId = `thumbnail-${crypto.randomUUID()}`; - const handled: Array<{ readonly documentId: string; readonly seq: number }> = []; + it("delivers and acknowledges an idle-document revision", () => + Effect.runPromise( + Effect.gen(function* () { + const config = getSelfhostRuntimeConfig(); + const documentId = `thumbnail-${generateId("test")}`; + const handled: Array<{ readonly documentId: string; readonly seq: number }> = []; - try { - await Effect.runPromise( - Effect.scoped( + const cleanup = Effect.gen(function* () { + const db = yield* Db; + // The cluster queue driver hands the store a JSON string, which the + // store then JSON-encodes into `element`, so the body is doubly + // encoded: unwrap the outer JSON scalar before reading its fields. + yield* db.execute(sql` + DELETE FROM effect_queue + WHERE queue_name = ${mimicDocumentIdleQueueName} + AND (element::jsonb #>> '{}')::jsonb ->> 'documentId' = ${documentId} + `); + }).pipe(Effect.provide(Db.layer(config.platformDatabase)), Effect.orDie); + + yield* Effect.scoped( Effect.gen(function* () { const publish = yield* makeSelfhostMimicDocumentIdlePublisher; const service = PaywallThumbnailService.of({ @@ -37,29 +50,17 @@ describe("self-host thumbnail queue", () => { ); yield* publish({ collectionId: "collection-1", documentId, seq: 17 }); - const deadline = Date.now() + 10_000; - while (handled.length === 0 && Date.now() < deadline) { + const deadline = (yield* Clock.currentTimeMillis) + 10_000; + while (handled.length === 0 && (yield* Clock.currentTimeMillis) < deadline) { yield* Effect.sleep("25 millis"); } }), - ).pipe(Effect.provide(makeSelfhostAnalyticsRuntimeLive(config))), - ); - } finally { - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - // The cluster queue driver hands the store a JSON string, which the - // store then JSON-encodes into `element`, so the body is doubly - // encoded: unwrap the outer JSON scalar before reading its fields. - yield* db.execute(sql` - DELETE FROM effect_queue - WHERE queue_name = ${mimicDocumentIdleQueueName} - AND (element::jsonb #>> '{}')::jsonb ->> 'documentId' = ${documentId} - `); - }).pipe(Effect.provide(Db.layer(config.platformDatabase))), - ); - } + ).pipe( + Effect.provide(makeSelfhostAnalyticsRuntimeLive(config)), + Effect.ensuring(cleanup), + ); - expect(handled).toEqual([{ documentId, seq: 17 }]); - }); + expect(handled).toEqual([{ documentId, seq: 17 }]); + }), + )); }); diff --git a/apps/backend/tests/Thumbnails.test.ts b/apps/backend/tests/Thumbnails.test.ts index 765212ea9..76ccbb6ff 100644 --- a/apps/backend/tests/Thumbnails.test.ts +++ b/apps/backend/tests/Thumbnails.test.ts @@ -14,83 +14,100 @@ import { SelfhostSnapshotImageRendererLive, } from "../src/backend/Thumbnails.ts"; -describe("self-host paywall thumbnail renderer", () => { - it("provides the manifest cache required by the thumbnail service", async () => { - const renderer = Layer.succeed(SnapshotImageRenderer, { - render: () => Effect.succeed(new Uint8Array()), - }); - const dependencies = Layer.mergeAll( - Layer.succeed(Db, {} as never), - Layer.succeed(MimicHost, {} as never), - Layer.succeed(PaywallArtifactStore, {} as never), - Layer.succeed(ComponentCompiler, {} as never), - Layer.succeed(PublicFileStore, {} as never), - ); +/** + * Stub for a service the subject never touches. Member access fails loudly as a + * defect instead of silently yielding `undefined`, so a future dependency on one + * of these layers surfaces immediately rather than as a confusing crash. + */ +const unusedService = (): A => + new Proxy(Object.create(null), { + get: (_target, property) => { + if (typeof property === "symbol") return undefined; + return Effect.runSync( + Effect.die(new Error(`unused test service member accessed: ${property}`)), + ); + }, + }); - const context = await Effect.runPromise( - Effect.scoped( - Layer.build(makeSelfhostPaywallThumbnailServiceLive({}, renderer)).pipe( - Effect.provide(dependencies), - ), - ), - ); +describe("self-host paywall thumbnail renderer", () => { + it("provides the manifest cache required by the thumbnail service", () => + Effect.runPromise( + Effect.gen(function* () { + const renderer = Layer.succeed(SnapshotImageRenderer, { + render: () => Effect.succeed(new Uint8Array()), + }); + const dependencies = Layer.mergeAll( + Layer.succeed(Db, unusedService()), + Layer.succeed(MimicHost, unusedService()), + Layer.succeed(PaywallArtifactStore, unusedService()), + Layer.succeed(ComponentCompiler, unusedService()), + Layer.succeed(PublicFileStore, unusedService()), + ); - expect(Context.get(context, PaywallThumbnailService)).toBeDefined(); - }); + const context = yield* Effect.scoped( + Layer.build(makeSelfhostPaywallThumbnailServiceLive({}, renderer)).pipe( + Effect.provide(dependencies), + ), + ); - it("renders static paywall HTML through the screenshot port", async () => { - const screenshots: string[] = []; - const png = new Uint8Array([137, 80, 78, 71]); - const screenshot = Layer.succeed( - HtmlScreenshot, - HtmlScreenshot.of({ - screenshot: (options) => - Effect.sync(() => { - screenshots.push(options.html); - return png; - }), + expect(Context.get(context, PaywallThumbnailService)).toBeDefined(); }), - ); + )); - const rendered = await Effect.runPromise( + it("renders static paywall HTML through the screenshot port", () => + Effect.runPromise( Effect.gen(function* () { - const renderer = yield* SnapshotImageRenderer; - return yield* renderer.render({ - componentTrees: {}, - localComponentTrees: {}, - deviceScaleFactor: 2, - height: 812, - snapshot: { - type: "root", - id: "root", - parentId: null, - pos: "a0", - data: { name: "Paywall" }, - children: [], - }, - width: 375, - }); - }).pipe( - Effect.provide( - SelfhostSnapshotImageRendererLive.pipe( - Layer.provide(screenshot), - Layer.provide( - Layer.succeed(PublicFileStore, { - publicBaseUrl: "https://files.test", - publicUrl: (key) => `https://files.test/files/${key}`, - putObject: () => Effect.void, - getObject: () => Effect.succeed(null), - deleteObject: () => Effect.void, + const screenshots: string[] = []; + const png = new Uint8Array([137, 80, 78, 71]); + const screenshot = Layer.succeed( + HtmlScreenshot, + HtmlScreenshot.of({ + screenshot: (options) => + Effect.sync(() => { + screenshots.push(options.html); + return png; }), + }), + ); + + const rendered = yield* Effect.gen(function* () { + const renderer = yield* SnapshotImageRenderer; + return yield* renderer.render({ + componentTrees: {}, + localComponentTrees: {}, + deviceScaleFactor: 2, + height: 812, + snapshot: { + type: "root", + id: "root", + parentId: null, + pos: "a0", + data: { name: "Paywall" }, + children: [], + }, + width: 375, + }); + }).pipe( + Effect.provide( + SelfhostSnapshotImageRendererLive.pipe( + Layer.provide(screenshot), + Layer.provide( + Layer.succeed(PublicFileStore, { + publicBaseUrl: "https://files.test", + publicUrl: (key) => `https://files.test/files/${key}`, + putObject: () => Effect.void, + getObject: () => Effect.succeed(null), + deleteObject: () => Effect.void, + }), + ), ), ), - ), - ), - ); + ); - expect(rendered).toEqual(png); - expect(screenshots).toHaveLength(1); - expect(screenshots[0]).toContain('id="paywall-root"'); - expect(screenshots[0]).not.toContain("__VOIDHASH_PAYWALL__"); - }); + expect(rendered).toEqual(png); + expect(screenshots).toHaveLength(1); + expect(screenshots[0]).toContain('id="paywall-root"'); + expect(screenshots[0]).not.toContain("__VOIDHASH_PAYWALL__"); + }), + )); }); diff --git a/apps/backend/tests/WorkflowComposition.integration.test.ts b/apps/backend/tests/WorkflowComposition.integration.test.ts index 55b3e1c06..beedc8439 100644 --- a/apps/backend/tests/WorkflowComposition.integration.test.ts +++ b/apps/backend/tests/WorkflowComposition.integration.test.ts @@ -1,8 +1,8 @@ import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; import { DeliverWebhookRegistration } from "@voidhash/core/workflows/DeliverWebhook"; import { DeliverWebhook } from "@voidhash/core/workflows/definitions"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, WebhookDeliveryStatus, @@ -12,119 +12,76 @@ import { webhookDeliveryAttempts, webhookEndpoints, } from "@voidhash/db"; -import { Effect, Layer } from "effect"; +import { Clock, Data, DateTime, Effect, Layer, Schema } from "effect"; import * as Workflow from "@voidhash/platform/Workflow"; import { describe, expect, it } from "vitest"; import { makeSelfhostPlatformLayers } from "../src/backend/PlatformProfile.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; -describe("self-host workflow composition", () => { - it("delivers a webhook through the durable cluster runner", async () => { - const receivedBodies: string[] = []; - const server = createServer((request, response) => { - const chunks: Buffer[] = []; - request.on("data", (chunk: Buffer) => chunks.push(chunk)); - request.on("end", () => { - receivedBodies.push(Buffer.concat(chunks).toString("utf8")); - response.writeHead(204).end(); - }); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); +class TestServerAddressError extends Data.TaggedError("TestServerAddressError")<{ + readonly message: string; +}> {} - const config = getSelfhostRuntimeConfig(); - const address = server.address() as AddressInfo; - const suffix = crypto.randomUUID(); - const endpointId = `webhookEndpoint_${suffix}`; - const deliveryId = `webhookDelivery_${suffix}`; - const database = Db.layer(config.database); - const platformDatabase = Db.layer(config.platformDatabase); - const platform = makeSelfhostPlatformLayers(config); - const workflowRuntime = Layer.merge(platform.workflowRunner, platform.runtime); +const WebhookPayload = Schema.Struct({ deliveryId: Schema.String }); +const encodeWebhookPayload = Schema.encodeSync(Schema.fromJsonString(WebhookPayload)); - try { - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - yield* db.insert(webhookEndpoints).values({ - events: ["person.created"], - id: endpointId, - name: "workflow integration", - projectId: `project_${suffix}`, - secret: "whsec_integration", - url: `http://127.0.0.1:${address.port}/webhook`, +describe("self-host workflow composition", () => { + it("delivers a webhook through the durable cluster runner", () => + Effect.runPromise( + Effect.gen(function* () { + const receivedBodies: string[] = []; + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + receivedBodies.push(Buffer.concat(chunks).toString("utf8")); + response.writeHead(204).end(); }); - yield* db.insert(webhookDeliveries).values({ - eventOccurredAt: new Date(), - eventType: "person.created", - id: deliveryId, - payload: { deliveryId }, - projectId: `project_${suffix}`, - webhookEndpointId: endpointId, + }); + yield* Effect.callback((resume) => { + const onError = (error: Error) => resume(Effect.fail(error)); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resume(Effect.void); }); - }).pipe(Effect.provide(database)), - ); + }); - const attempts = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* DeliverWebhookRegistration.register(database); - yield* Workflow.execute(DeliverWebhook, { - attemptNumber: 1, - deliveryId, - endpointId, - eventType: "person.created", - payload: { deliveryId }, - secret: "whsec_integration", - url: `http://127.0.0.1:${address.port}/webhook`, - }); + const config = getSelfhostRuntimeConfig(); + const address = server.address(); + if (address === null || typeof address === "string") { + return yield* new TestServerAddressError({ + message: "Test webhook receiver did not expose a TCP address", + }); + } + const suffix = generateId("test"); + const endpointId = `webhookEndpoint_${suffix}`; + const deliveryId = `webhookDelivery_${suffix}`; + const database = Db.layer(config.database); + const platformDatabase = Db.layer(config.platformDatabase); + const platform = makeSelfhostPlatformLayers(config); + const workflowRuntime = Layer.merge(platform.workflowRunner, platform.runtime); + const teardown = Effect.gen(function* () { + yield* Effect.gen(function* () { const db = yield* Db; - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - const row = yield* db.query.webhookDeliveries.findFirst({ - where: { id: deliveryId }, - }); - if (row?.status === WebhookDeliveryStatus.Succeeded) { - return yield* db.query.webhookDeliveryAttempts.findMany({ - where: { webhookDeliveryId: deliveryId }, - }); - } - yield* Effect.sleep("25 millis"); - } - return yield* Effect.die("webhook workflow timed out"); - }).pipe(Effect.provide(database), Effect.provide(workflowRuntime)), - ), - ); - - expect(receivedBodies).toEqual([JSON.stringify({ deliveryId })]); - expect(attempts).toHaveLength(1); - expect(attempts[0]).toMatchObject({ attemptNumber: 1, statusCode: 204, succeeded: true }); - } finally { - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - yield* db - .delete(webhookDeliveryAttempts) - .where(eq(webhookDeliveryAttempts.webhookDeliveryId, deliveryId)); - yield* db.delete(webhookDeliveries).where(eq(webhookDeliveries.id, deliveryId)); - yield* db.delete(webhookEndpoints).where(eq(webhookEndpoints.id, endpointId)); - }).pipe(Effect.provide(database)), - ); - await Effect.runPromise( - Effect.gen(function* () { - const db = yield* Db; - // The cluster workflow engine keeps no tables of its own: an - // execution, its activities, its deferreds, and its durable clocks - // are all rows in `cluster_messages` addressed to the same - // `entity_id` (the hashed execution ID). Only the `run` row carries - // the workflow payload, so it is what maps a delivery back to an - // execution. Those rows live in the platform database, which is a - // different connection from the application tables above. - yield* db.execute(sql` + yield* db + .delete(webhookDeliveryAttempts) + .where(eq(webhookDeliveryAttempts.webhookDeliveryId, deliveryId)); + yield* db.delete(webhookDeliveries).where(eq(webhookDeliveries.id, deliveryId)); + yield* db.delete(webhookEndpoints).where(eq(webhookEndpoints.id, endpointId)); + }).pipe(Effect.provide(database)); + yield* Effect.gen(function* () { + const db = yield* Db; + // The cluster workflow engine keeps no tables of its own: an + // execution, its activities, its deferreds, and its durable clocks + // are all rows in `cluster_messages` addressed to the same + // `entity_id` (the hashed execution ID). Only the `run` row carries + // the workflow payload, so it is what maps a delivery back to an + // execution. Those rows live in the platform database, which is a + // different connection from the application tables above. + yield* db.execute(sql` DELETE FROM cluster_replies WHERE request_id IN ( SELECT request_id FROM cluster_messages @@ -134,16 +91,75 @@ describe("self-host workflow composition", () => { ) ) `); - yield* db.execute(sql` + yield* db.execute(sql` DELETE FROM cluster_messages WHERE entity_id IN ( SELECT entity_id FROM cluster_messages WHERE tag = 'run' AND payload::jsonb ->> 'deliveryId' = ${deliveryId} ) `); - }).pipe(Effect.provide(platformDatabase)), - ); - await new Promise((resolve) => server.close(() => resolve())); - } - }); + }).pipe(Effect.provide(platformDatabase)); + yield* Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }); + }).pipe(Effect.orDie); + + return yield* Effect.gen(function* () { + const occurredAt = yield* DateTime.nowAsDate; + yield* Effect.gen(function* () { + const db = yield* Db; + yield* db.insert(webhookEndpoints).values({ + events: ["person.created"], + id: endpointId, + name: "workflow integration", + projectId: `project_${suffix}`, + secret: "whsec_integration", + url: `http://127.0.0.1:${address.port}/webhook`, + }); + yield* db.insert(webhookDeliveries).values({ + eventOccurredAt: occurredAt, + eventType: "person.created", + id: deliveryId, + payload: { deliveryId }, + projectId: `project_${suffix}`, + webhookEndpointId: endpointId, + }); + }).pipe(Effect.provide(database)); + + const attempts = yield* Effect.scoped( + Effect.gen(function* () { + yield* DeliverWebhookRegistration.register(database); + yield* Workflow.execute(DeliverWebhook, { + attemptNumber: 1, + deliveryId, + endpointId, + eventType: "person.created", + payload: { deliveryId }, + secret: "whsec_integration", + url: `http://127.0.0.1:${address.port}/webhook`, + }); + + const db = yield* Db; + const deadline = (yield* Clock.currentTimeMillis) + 10_000; + while ((yield* Clock.currentTimeMillis) < deadline) { + const row = yield* db.query.webhookDeliveries.findFirst({ + where: { id: deliveryId }, + }); + if (row?.status === WebhookDeliveryStatus.Succeeded) { + return yield* db.query.webhookDeliveryAttempts.findMany({ + where: { webhookDeliveryId: deliveryId }, + }); + } + yield* Effect.sleep("25 millis"); + } + return yield* Effect.die("webhook workflow timed out"); + }).pipe(Effect.provide(database), Effect.provide(workflowRuntime)), + ); + + expect(receivedBodies).toEqual([encodeWebhookPayload({ deliveryId })]); + expect(attempts).toHaveLength(1); + expect(attempts[0]).toMatchObject({ attemptNumber: 1, statusCode: 204, succeeded: true }); + }).pipe(Effect.ensuring(teardown)); + }), + )); }); diff --git a/apps/backend/tests/WorkflowRegistry.integration.test.ts b/apps/backend/tests/WorkflowRegistry.integration.test.ts index f6c19f562..8616eeadd 100644 --- a/apps/backend/tests/WorkflowRegistry.integration.test.ts +++ b/apps/backend/tests/WorkflowRegistry.integration.test.ts @@ -1,5 +1,4 @@ import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; import { AnalyticsDispatchService } from "@voidhash/core/services/analyticsIngest/AnalyticsDispatchService"; import { @@ -14,6 +13,7 @@ import { StripeReplayParkedNotifications, } from "@voidhash/core/workflows/definitions"; import { backendWorkflows } from "@voidhash/core/workflows/registry"; +import { generateId } from "@voidhash/core/utils/generate-id"; import { Db, type InsertPurchaseLedger, @@ -30,361 +30,398 @@ import { webhookDeliveryAttempts, webhookEndpoints, } from "@voidhash/db"; +import { causeMessage, constant } from "@voidhash/lib/lang"; import * as Workflow from "@voidhash/platform/Workflow"; -import { Effect, Exit, Layer } from "effect"; +import { Clock, Data, DateTime, Effect, Exit, Layer, Schema } from "effect"; import { describe, expect, it } from "vitest"; import { makeSelfhostPlatformLayers } from "../src/backend/PlatformProfile.ts"; import { getSelfhostRuntimeConfig } from "../src/config.ts"; +class WorkflowRegistryTestError extends Data.TaggedError("WorkflowRegistryTestError")<{ + readonly message: string; +}> {} + +const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString); + const FX_UPDATE_UNIX = 1_767_225_600; -const FX_AS_OF_DATE = new Date(FX_UPDATE_UNIX * 1_000); +const FX_AS_OF_DATE = DateTime.toDateUtc(DateTime.makeUnsafe(FX_UPDATE_UNIX * 1_000)); const FX_CURRENCY = "XTS"; const DAY_MS = 24 * 60 * 60 * 1_000; describe("self-host workflow registry", () => { - it("executes every workflow through the Postgres-backed cluster runner", async () => { - const marker = `workflow-coverage-${crypto.randomUUID()}`; - const webhookEndpointId = `${marker}-endpoint`; - const webhookDeliveryId = `${marker}-delivery`; - const ledgerId = `${marker}-ledger`; - const expireId = `${marker}-expire`; - const appProductId = `${marker}-app-product`; - const appSdkId = `${marker}-app-sdk`; - const googleId = `${marker}-google`; - const stripeId = `${marker}-stripe`; - const expireTriggeredAt = new Date().toISOString(); - const notificationIds = [expireId, appProductId, appSdkId, googleId, stripeId]; - const receivedWebhookBodies: string[] = []; - let fxRequests = 0; + it("executes every workflow through the Postgres-backed cluster runner", () => + Effect.runPromise( + Effect.gen(function* () { + const marker = `workflow-coverage-${generateId("test")}`; + const webhookEndpointId = `${marker}-endpoint`; + const webhookDeliveryId = `${marker}-delivery`; + const ledgerId = `${marker}-ledger`; + const expireId = `${marker}-expire`; + const appProductId = `${marker}-app-product`; + const appSdkId = `${marker}-app-sdk`; + const googleId = `${marker}-google`; + const stripeId = `${marker}-stripe`; + const expireTriggeredAt = (yield* DateTime.nowAsDate).toISOString(); + const notificationIds = [expireId, appProductId, appSdkId, googleId, stripeId]; + const receivedWebhookBodies: string[] = []; + let fxRequests = 0; - const server = createServer((request, response) => { - if (request.url?.endsWith("/latest/USD")) { - fxRequests++; - response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - base_code: "USD", - conversion_rates: { [FX_CURRENCY]: 2 }, - result: "success", - time_last_update_unix: FX_UPDATE_UNIX, - }), - ); - return; - } + const server = createServer((request, response) => { + if (request.url?.endsWith("/latest/USD")) { + fxRequests++; + response.writeHead(200, { "content-type": "application/json" }); + response.end( + encodeJson({ + base_code: "USD", + conversion_rates: { [FX_CURRENCY]: 2 }, + result: "success", + time_last_update_unix: FX_UPDATE_UNIX, + }), + ); + return; + } - const chunks: Buffer[] = []; - request.on("data", (chunk: Buffer) => chunks.push(chunk)); - request.on("end", () => { - receivedWebhookBodies.push(Buffer.concat(chunks).toString("utf8")); - response.writeHead(204).end(); - }); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + receivedWebhookBodies.push(Buffer.concat(chunks).toString("utf8")); + response.writeHead(204).end(); + }); + }); + const address = yield* Effect.callback< + { readonly port: number }, + WorkflowRegistryTestError + >((resume) => { + const onError = (error: Error) => + resume(Effect.fail(new WorkflowRegistryTestError({ message: causeMessage(error) }))); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + const listening = server.address(); + if (listening === null || typeof listening === "string") { + resume( + Effect.fail( + new WorkflowRegistryTestError({ + message: "HTTP server did not expose a TCP port", + }), + ), + ); + return; + } + resume(Effect.succeed({ port: listening.port })); + }); + }); - const address = server.address() as AddressInfo; - const originalFxBaseUrl = process.env.EXCHANGE_RATE_API_BASE_URL; - const originalFxApiKey = process.env.EXCHANGE_RATE_API_KEY; - process.env.EXCHANGE_RATE_API_BASE_URL = `http://127.0.0.1:${address.port}/fx`; - process.env.EXCHANGE_RATE_API_KEY = "integration"; + const originalFxBaseUrl = process.env.EXCHANGE_RATE_API_BASE_URL; + const originalFxApiKey = process.env.EXCHANGE_RATE_API_KEY; + process.env.EXCHANGE_RATE_API_BASE_URL = `http://127.0.0.1:${address.port}/fx`; + process.env.EXCHANGE_RATE_API_KEY = "integration"; - const config = getSelfhostRuntimeConfig(); - const database = Db.layer(config.database); - const platformDatabase = Db.layer(config.platformDatabase); - const platform = makeSelfhostPlatformLayers(config); - const workflowRuntime = Layer.merge(platform.workflowRunner, platform.runtime); - const workflowInfra = Layer.merge(database, AnalyticsDispatchService.noop); + const config = getSelfhostRuntimeConfig(); + const database = Db.layer(config.database); + const platformDatabase = Db.layer(config.platformDatabase); + const platform = makeSelfhostPlatformLayers(config); + const workflowRuntime = Layer.merge(platform.workflowRunner, platform.runtime); + const workflowInfra = Layer.merge(database, AnalyticsDispatchService.noop); - const cleanupApplicationRows = Effect.gen(function* () { - const db = yield* Db; - yield* db - .delete(webhookDeliveryAttempts) - .where(eq(webhookDeliveryAttempts.webhookDeliveryId, webhookDeliveryId)) - .pipe(Effect.ignore); - yield* db - .delete(webhookDeliveries) - .where(eq(webhookDeliveries.id, webhookDeliveryId)) - .pipe(Effect.ignore); - yield* db - .delete(webhookEndpoints) - .where(eq(webhookEndpoints.id, webhookEndpointId)) - .pipe(Effect.ignore); - yield* db - .delete(paymentProviderNotificationProcessed) - .where(inArray(paymentProviderNotificationProcessed.id, notificationIds)) - .pipe(Effect.ignore); - yield* db.delete(purchaseLedger).where(eq(purchaseLedger.id, ledgerId)).pipe(Effect.ignore); - yield* db - .delete(fxRates) - .where(and(eq(fxRates.currency, FX_CURRENCY), eq(fxRates.asOfDate, FX_AS_OF_DATE))) - .pipe(Effect.ignore); - }).pipe(Effect.provide(database)); + const cleanupApplicationRows = Effect.gen(function* () { + const db = yield* Db; + yield* db + .delete(webhookDeliveryAttempts) + .where(eq(webhookDeliveryAttempts.webhookDeliveryId, webhookDeliveryId)) + .pipe(Effect.ignore); + yield* db + .delete(webhookDeliveries) + .where(eq(webhookDeliveries.id, webhookDeliveryId)) + .pipe(Effect.ignore); + yield* db + .delete(webhookEndpoints) + .where(eq(webhookEndpoints.id, webhookEndpointId)) + .pipe(Effect.ignore); + yield* db + .delete(paymentProviderNotificationProcessed) + .where(inArray(paymentProviderNotificationProcessed.id, notificationIds)) + .pipe(Effect.ignore); + yield* db + .delete(purchaseLedger) + .where(eq(purchaseLedger.id, ledgerId)) + .pipe(Effect.ignore); + yield* db + .delete(fxRates) + .where(and(eq(fxRates.currency, FX_CURRENCY), eq(fxRates.asOfDate, FX_AS_OF_DATE))) + .pipe(Effect.ignore); + }).pipe(Effect.provide(database)); - const cleanupWorkflowRows = Effect.gen(function* () { - const db = yield* Db; - const markerPattern = `%${marker}%`; - yield* db.execute(sql` - DELETE FROM cluster_replies - WHERE request_id IN ( - SELECT request_id FROM cluster_messages - WHERE entity_id IN ( - SELECT entity_id FROM cluster_messages - WHERE tag = 'run' - AND ( - payload::jsonb::text LIKE ${markerPattern} - OR payload::jsonb ->> 'triggeredAt' = ${expireTriggeredAt} + const cleanupWorkflowRows = Effect.gen(function* () { + const db = yield* Db; + const markerPattern = `%${marker}%`; + yield* db.execute(sql` + DELETE FROM cluster_replies + WHERE request_id IN ( + SELECT request_id FROM cluster_messages + WHERE entity_id IN ( + SELECT entity_id FROM cluster_messages + WHERE tag = 'run' + AND ( + payload::jsonb::text LIKE ${markerPattern} + OR payload::jsonb ->> 'triggeredAt' = ${expireTriggeredAt} + ) ) - ) - ) - `); - yield* db.execute(sql` - DELETE FROM cluster_messages - WHERE entity_id IN ( - SELECT entity_id FROM cluster_messages - WHERE tag = 'run' - AND ( - payload::jsonb::text LIKE ${markerPattern} - OR payload::jsonb ->> 'triggeredAt' = ${expireTriggeredAt} ) - ) - `); - }).pipe(Effect.provide(platformDatabase), Effect.ignore); - - try { - await Effect.runPromise(cleanupApplicationRows); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const db = yield* Db; + `); + yield* db.execute(sql` + DELETE FROM cluster_messages + WHERE entity_id IN ( + SELECT entity_id FROM cluster_messages + WHERE tag = 'run' + AND ( + payload::jsonb::text LIKE ${markerPattern} + OR payload::jsonb ->> 'triggeredAt' = ${expireTriggeredAt} + ) + ) + `); + }).pipe(Effect.provide(platformDatabase), Effect.ignore); - yield* db.insert(webhookEndpoints).values({ - events: ["person.created"], - id: webhookEndpointId, - name: "workflow registry integration", - projectId: `${marker}-project`, - secret: "whsec_integration", - url: `http://127.0.0.1:${address.port}/webhook`, - }); - yield* db.insert(webhookDeliveries).values({ - eventOccurredAt: new Date(), - eventType: "person.created", - id: webhookDeliveryId, - payload: { marker }, - projectId: `${marker}-project`, - webhookEndpointId, - }); + const body = Effect.gen(function* () { + yield* cleanupApplicationRows; + yield* Effect.scoped( + Effect.gen(function* () { + const db = yield* Db; - const ledgerRow: InsertPurchaseLedger = { - attemptCount: 0, - claimedAt: null, - claimedBy: null, - eventsPayload: [], - id: ledgerId, - idempotencyKey: `${marker}-ledger-key`, - lastError: null, - nextAttemptAt: null, - organizationId: `${marker}-organization`, - personId: `${marker}-person`, - projectId: `${marker}-project`, - providerEventType: "integration-test", - providerId: "stripe", - publishedAt: null, - rawProviderPayload: null, - resultPayload: {}, - source: "webhook", - status: PurchaseLedgerStatus.Pending, - }; - yield* db.insert(purchaseLedger).values(ledgerRow); + yield* db.insert(webhookEndpoints).values({ + events: ["person.created"], + id: webhookEndpointId, + name: "workflow registry integration", + projectId: `${marker}-project`, + secret: "whsec_integration", + url: `http://127.0.0.1:${address.port}/webhook`, + }); + yield* db.insert(webhookDeliveries).values({ + eventOccurredAt: yield* DateTime.nowAsDate, + eventType: "person.created", + id: webhookDeliveryId, + payload: { marker }, + projectId: `${marker}-project`, + webhookEndpointId, + }); - yield* db.insert(paymentProviderNotificationProcessed).values([ - { - id: expireId, - notificationType: "integration-test", - notificationUuid: `${expireId}-uuid`, - parkedRawPayload: null, - parkedUntilOriginalTransactionId: `${expireId}-original`, - paymentProviderConfigurationId: `${expireId}-config`, - processedAt: new Date(Date.now() - 91 * DAY_MS), - providerId: "apple-app-store", - result: "parked_pending_sdk_confirmation", - source: "webhook", - }, - { - id: appProductId, - notificationType: "integration-test", - notificationUuid: `${appProductId}-uuid`, - parkedRawPayload: null, - parkedUntilProviderProductKey: `${appProductId}-key`, - paymentProviderConfigurationId: `${appProductId}-config`, - providerId: "apple-app-store", - result: "parked_pending_product_mapping", - source: "webhook", - }, - { - id: appSdkId, - notificationType: "integration-test", - notificationUuid: `${appSdkId}-uuid`, - parkedRawPayload: null, - parkedUntilOriginalTransactionId: `${appSdkId}-original`, - paymentProviderConfigurationId: `${appSdkId}-config`, - providerId: "apple-app-store", - result: "parked_pending_sdk_confirmation", - source: "webhook", - }, - { - id: googleId, - notificationType: "integration-test", - notificationUuid: `${googleId}-uuid`, - parkedRawPayload: null, - parkedUntilProviderProductKey: `${googleId}-key`, - paymentProviderConfigurationId: `${googleId}-config`, - providerId: "google-play", - result: "parked_pending_product_mapping", - source: "webhook", - }, - { - id: stripeId, - notificationType: "integration-test", - notificationUuid: `${stripeId}-uuid`, - parkedRawPayload: null, - parkedUntilProviderProductKey: `${stripeId}-key`, - paymentProviderConfigurationId: `${stripeId}-config`, + const ledgerRow: InsertPurchaseLedger = { + attemptCount: 0, + claimedAt: null, + claimedBy: null, + eventsPayload: [], + id: ledgerId, + idempotencyKey: `${marker}-ledger-key`, + lastError: null, + nextAttemptAt: null, + organizationId: `${marker}-organization`, + personId: `${marker}-person`, + projectId: `${marker}-project`, + providerEventType: "integration-test", providerId: "stripe", - result: "parked_pending_product_mapping", + publishedAt: null, + rawProviderPayload: null, + resultPayload: {}, source: "webhook", - }, - ]); + status: PurchaseLedgerStatus.Pending, + }; + yield* db.insert(purchaseLedger).values(ledgerRow); - yield* Effect.forEach( - backendWorkflows, - (registration) => registration.register(workflowInfra), - { discard: true }, - ); + yield* db.insert(paymentProviderNotificationProcessed).values([ + { + id: expireId, + notificationType: "integration-test", + notificationUuid: `${expireId}-uuid`, + parkedRawPayload: null, + parkedUntilOriginalTransactionId: `${expireId}-original`, + paymentProviderConfigurationId: `${expireId}-config`, + processedAt: DateTime.toDateUtc( + DateTime.makeUnsafe((yield* Clock.currentTimeMillis) - 91 * DAY_MS), + ), + providerId: "apple-app-store", + result: "parked_pending_sdk_confirmation", + source: "webhook", + }, + { + id: appProductId, + notificationType: "integration-test", + notificationUuid: `${appProductId}-uuid`, + parkedRawPayload: null, + parkedUntilProviderProductKey: `${appProductId}-key`, + paymentProviderConfigurationId: `${appProductId}-config`, + providerId: "apple-app-store", + result: "parked_pending_product_mapping", + source: "webhook", + }, + { + id: appSdkId, + notificationType: "integration-test", + notificationUuid: `${appSdkId}-uuid`, + parkedRawPayload: null, + parkedUntilOriginalTransactionId: `${appSdkId}-original`, + paymentProviderConfigurationId: `${appSdkId}-config`, + providerId: "apple-app-store", + result: "parked_pending_sdk_confirmation", + source: "webhook", + }, + { + id: googleId, + notificationType: "integration-test", + notificationUuid: `${googleId}-uuid`, + parkedRawPayload: null, + parkedUntilProviderProductKey: `${googleId}-key`, + paymentProviderConfigurationId: `${googleId}-config`, + providerId: "google-play", + result: "parked_pending_product_mapping", + source: "webhook", + }, + { + id: stripeId, + notificationType: "integration-test", + notificationUuid: `${stripeId}-uuid`, + parkedRawPayload: null, + parkedUntilProviderProductKey: `${stripeId}-key`, + paymentProviderConfigurationId: `${stripeId}-config`, + providerId: "stripe", + result: "parked_pending_product_mapping", + source: "webhook", + }, + ]); - const webhookResult = yield* Workflow.execute(DeliverWebhook, { - attemptNumber: 1, - deliveryId: webhookDeliveryId, - endpointId: webhookEndpointId, - eventType: "person.created", - payload: { marker }, - secret: "whsec_integration", - url: `http://127.0.0.1:${address.port}/webhook`, - }); - expect(webhookResult).toBeUndefined(); - expect(receivedWebhookBodies).toEqual([JSON.stringify({ marker })]); - expect( - (yield* db.query.webhookDeliveries.findFirst({ where: { id: webhookDeliveryId } })) - ?.status, - ).toBe(WebhookDeliveryStatus.Succeeded); + yield* Effect.forEach( + backendWorkflows, + (registration) => registration.register(workflowInfra), + { discard: true }, + ); - const fxPayload = { runId: `${marker}-fx` }; - expect(yield* Workflow.execute(FxRateSync, fxPayload)).toEqual({ refreshedCount: 1 }); - expect(yield* Workflow.execute(FxRateSync, fxPayload)).toEqual({ refreshedCount: 1 }); - expect(fxRequests).toBe(1); - expect( - yield* db.query.fxRates.findFirst({ - where: { asOfDate: { eq: FX_AS_OF_DATE }, currency: FX_CURRENCY }, - }), - ).toMatchObject({ - currency: FX_CURRENCY, - source: `exchange-rate-api:latest:${FX_UPDATE_UNIX}`, - usdRate: 500_000, - }); + const webhookResult = yield* Workflow.execute(DeliverWebhook, { + attemptNumber: 1, + deliveryId: webhookDeliveryId, + endpointId: webhookEndpointId, + eventType: "person.created", + payload: { marker }, + secret: "whsec_integration", + url: `http://127.0.0.1:${address.port}/webhook`, + }); + expect(webhookResult).toBeUndefined(); + expect(receivedWebhookBodies).toEqual([encodeJson({ marker })]); + expect( + (yield* db.query.webhookDeliveries.findFirst({ where: { id: webhookDeliveryId } })) + ?.status, + ).toBe(WebhookDeliveryStatus.Succeeded); - const drain = yield* Workflow.execute(PurchaseLedgerDrain, { - runId: `${marker}-drain`, - }); - expect(drain.batches).toBeGreaterThanOrEqual(1); - expect(drain.batches).toBeLessThanOrEqual(10); - expect( - (yield* db.query.purchaseLedger.findFirst({ where: { id: ledgerId } }))?.status, - ).toBe(PurchaseLedgerStatus.Published); + const fxPayload = { runId: `${marker}-fx` }; + expect(yield* Workflow.execute(FxRateSync, fxPayload)).toEqual({ refreshedCount: 1 }); + expect(yield* Workflow.execute(FxRateSync, fxPayload)).toEqual({ refreshedCount: 1 }); + expect(fxRequests).toBe(1); + expect( + yield* db.query.fxRates.findFirst({ + where: { asOfDate: { eq: FX_AS_OF_DATE }, currency: FX_CURRENCY }, + }), + ).toMatchObject({ + currency: FX_CURRENCY, + source: `exchange-rate-api:latest:${FX_UPDATE_UNIX}`, + usdRate: 500_000, + }); - const expiry = yield* Workflow.execute(AppStoreExpireParkedNotifications, { - triggeredAt: expireTriggeredAt, - }); - expect(expiry.expired).toBeGreaterThanOrEqual(1); - expect( - (yield* db.query.paymentProviderNotificationProcessed.findFirst({ - where: { id: expireId }, - }))?.result, - ).toBe("expired"); + const drain = yield* Workflow.execute(PurchaseLedgerDrain, { + runId: `${marker}-drain`, + }); + expect(drain.batches).toBeGreaterThanOrEqual(1); + expect(drain.batches).toBeLessThanOrEqual(10); + expect( + (yield* db.query.purchaseLedger.findFirst({ where: { id: ledgerId } }))?.status, + ).toBe(PurchaseLedgerStatus.Published); - const replayExpected = { appliedCount: 0, failedCount: 1, totalParked: 1 }; - expect( - yield* Workflow.execute(AppStoreReplayParkedNotifications, { - paymentProviderConfigurationId: `${appProductId}-config`, - paymentProviderProductId: `${appProductId}-product`, - providerProductKey: `${appProductId}-key`, - requestedAt: `${marker}-app-product-request`, - }), - ).toEqual(replayExpected); - expect( - yield* Workflow.execute(AppStoreReplayParkedSdkNotifications, { - originalTransactionId: `${appSdkId}-original`, - paymentProviderConfigurationId: `${appSdkId}-config`, - requestedAt: `${marker}-app-sdk-request`, - }), - ).toEqual(replayExpected); - expect( - yield* Workflow.execute(GooglePlayReplayParkedNotifications, { - paymentProviderConfigurationId: `${googleId}-config`, - paymentProviderProductId: `${googleId}-product`, - providerProductKey: `${googleId}-key`, - requestedAt: `${marker}-google-request`, - }), - ).toEqual(replayExpected); - expect( - yield* Workflow.execute(StripeReplayParkedNotifications, { - paymentProviderConfigurationId: `${stripeId}-config`, - paymentProviderProductId: `${stripeId}-product`, - providerProductKey: `${stripeId}-key`, - requestedAt: `${marker}-stripe-request`, - }), - ).toEqual(replayExpected); + const expiry = yield* Workflow.execute(AppStoreExpireParkedNotifications, { + triggeredAt: expireTriggeredAt, + }); + expect(expiry.expired).toBeGreaterThanOrEqual(1); + expect( + (yield* db.query.paymentProviderNotificationProcessed.findFirst({ + where: { id: expireId }, + }))?.result, + ).toBe("expired"); - for (const [id, note] of [ - [appProductId, "parked_raw_payload missing or not a string"], - [appSdkId, "parked_raw_payload missing or not a string"], - [googleId, "parked_raw_payload missing"], - [stripeId, "parked_raw_payload missing or not a string"], - ] as const) { + const replayExpected = { appliedCount: 0, failedCount: 1, totalParked: 1 }; expect( - yield* db.query.paymentProviderNotificationProcessed.findFirst({ - where: { id }, + yield* Workflow.execute(AppStoreReplayParkedNotifications, { + paymentProviderConfigurationId: `${appProductId}-config`, + paymentProviderProductId: `${appProductId}-product`, + providerProductKey: `${appProductId}-key`, + requestedAt: `${marker}-app-product-request`, }), - ).toMatchObject({ - parkedRawPayload: null, - parkedUntilOriginalTransactionId: null, - parkedUntilProviderProductKey: null, - result: "failed", - resultNote: note, - }); - } + ).toEqual(replayExpected); + expect( + yield* Workflow.execute(AppStoreReplayParkedSdkNotifications, { + originalTransactionId: `${appSdkId}-original`, + paymentProviderConfigurationId: `${appSdkId}-config`, + requestedAt: `${marker}-app-sdk-request`, + }), + ).toEqual(replayExpected); + expect( + yield* Workflow.execute(GooglePlayReplayParkedNotifications, { + paymentProviderConfigurationId: `${googleId}-config`, + paymentProviderProductId: `${googleId}-product`, + providerProductKey: `${googleId}-key`, + requestedAt: `${marker}-google-request`, + }), + ).toEqual(replayExpected); + expect( + yield* Workflow.execute(StripeReplayParkedNotifications, { + paymentProviderConfigurationId: `${stripeId}-config`, + paymentProviderProductId: `${stripeId}-product`, + providerProductKey: `${stripeId}-key`, + requestedAt: `${marker}-stripe-request`, + }), + ).toEqual(replayExpected); - const reconcileExit = yield* Effect.exit( - Workflow.execute(AppStoreReconcileOriginalTransaction, { - originalTransactionId: `${marker}-missing-original`, - paymentProviderConfigurationId: `${marker}-missing-config`, - reason: "admin_repair", - triggeredAt: new Date().toISOString(), - }), - ); - expect(Exit.isFailure(reconcileExit)).toBe(true); - }).pipe(Effect.provide(database), Effect.provide(workflowRuntime)), - ), - ); - } finally { - await Effect.runPromise(cleanupApplicationRows); - await Effect.runPromise(cleanupWorkflowRows); - if (originalFxBaseUrl === undefined) delete process.env.EXCHANGE_RATE_API_BASE_URL; - else process.env.EXCHANGE_RATE_API_BASE_URL = originalFxBaseUrl; - if (originalFxApiKey === undefined) delete process.env.EXCHANGE_RATE_API_KEY; - else process.env.EXCHANGE_RATE_API_KEY = originalFxApiKey; - await new Promise((resolve) => server.close(() => resolve())); - } - }, 240_000); + for (const [id, note] of constant([ + [appProductId, "parked_raw_payload missing or not a string"], + [appSdkId, "parked_raw_payload missing or not a string"], + [googleId, "parked_raw_payload missing"], + [stripeId, "parked_raw_payload missing or not a string"], + ])) { + expect( + yield* db.query.paymentProviderNotificationProcessed.findFirst({ + where: { id }, + }), + ).toMatchObject({ + parkedRawPayload: null, + parkedUntilOriginalTransactionId: null, + parkedUntilProviderProductKey: null, + result: "failed", + resultNote: note, + }); + } + + const reconcileExit = yield* Effect.exit( + Workflow.execute(AppStoreReconcileOriginalTransaction, { + originalTransactionId: `${marker}-missing-original`, + paymentProviderConfigurationId: `${marker}-missing-config`, + reason: "admin_repair", + triggeredAt: (yield* DateTime.nowAsDate).toISOString(), + }), + ); + expect(Exit.isFailure(reconcileExit)).toBe(true); + }).pipe(Effect.provide(database), Effect.provide(workflowRuntime)), + ); + }); + + const cleanup = Effect.gen(function* () { + yield* cleanupApplicationRows; + yield* cleanupWorkflowRows; + if (originalFxBaseUrl === undefined) delete process.env.EXCHANGE_RATE_API_BASE_URL; + else process.env.EXCHANGE_RATE_API_BASE_URL = originalFxBaseUrl; + if (originalFxApiKey === undefined) delete process.env.EXCHANGE_RATE_API_KEY; + else process.env.EXCHANGE_RATE_API_KEY = originalFxApiKey; + yield* Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }); + }).pipe(Effect.orDie); + + yield* body.pipe(Effect.ensuring(cleanup)); + }), + ), 240_000); }); diff --git a/apps/backend/tests/Www.test.ts b/apps/backend/tests/Www.test.ts index f47a4df5d..acfba0dd9 100644 --- a/apps/backend/tests/Www.test.ts +++ b/apps/backend/tests/Www.test.ts @@ -1,58 +1,84 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { NodeFileSystem, NodePath } from "@effect/platform-node"; +import { Data, Effect, FileSystem, Layer, Path } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { describe, expect, it } from "vitest"; import { isWwwRequest, makeWwwRequestHandler } from "../src/www/Www.ts"; -const cleanups: Array<() => Promise> = []; +class TestServerAddressError extends Data.TaggedError("TestServerAddressError")<{ + readonly message: string; +}> {} -afterEach(async () => { - await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); -}); +const testServices = Layer.mergeAll( + NodeFileSystem.layer, + NodePath.layer, + FetchHttpClient.layer, +); -const startTestServer = async (clientDirectory: string) => { - const handler = makeWwwRequestHandler({ - clientDirectory, - fetch: (request) => new Response(`SSR ${new URL(request.url).pathname}`, { - headers: { "content-type": "text/plain" }, - }), - }); - const server = createServer((request, response) => { - handler(request, response).catch((error) => { - response.statusCode = 500; - response.end(String(error)); +/** Starts the WWW handler on an ephemeral port; the server closes with the scope. */ +const startTestServer = (clientDirectory: string) => + Effect.gen(function* () { + const handler = makeWwwRequestHandler({ + clientDirectory, + fetch: (request) => + new Response(`SSR ${new URL(request.url).pathname}`, { + headers: { "content-type": "text/plain" }, + }), }); + const server = createServer((request, response) => { + handler(request, response).catch((error: unknown) => { + response.statusCode = 500; + response.end(String(error)); + }); + }); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + server.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), + ); + const address = server.address(); + if (address === null || typeof address === "string") { + return yield* new TestServerAddressError({ + message: "Test server did not expose a TCP address", + }); + } + return `http://127.0.0.1:${address.port}`; }); - await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); - cleanups.push(() => new Promise((resolveClose) => server.close(() => resolveClose()))); - const address = server.address(); - if (address === null || typeof address === "string") { - throw new Error("Test server did not expose a TCP address"); - } - return `http://127.0.0.1:${address.port}`; -}; describe("WWW Node handler", () => { - it("serves built assets and falls back to SSR", async () => { - const root = await mkdtemp(join(tmpdir(), "voidhash-www-")); - cleanups.push(() => rm(root, { force: true, recursive: true })); - await mkdir(join(root, "assets")); - await writeFile(join(root, "assets", "app.js"), "export const ready = true;"); - const origin = await startTestServer(root); + it("serves built assets and falls back to SSR", () => + Effect.runPromise( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "voidhash-www-" }); + yield* fileSystem.makeDirectory(path.join(root, "assets")); + yield* fileSystem.writeFileString( + path.join(root, "assets", "app.js"), + "export const ready = true;", + ); + const origin = yield* startTestServer(root); + const client = yield* HttpClient.HttpClient; - const asset = await fetch(`${origin}/assets/app.js`); - expect(asset.status).toBe(200); - expect(asset.headers.get("cache-control")).toContain("immutable"); - expect(asset.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); - expect(await asset.text()).toBe("export const ready = true;"); + const asset = yield* client.get(`${origin}/assets/app.js`); + expect(asset.status).toBe(200); + expect(asset.headers["cache-control"]).toContain("immutable"); + expect(asset.headers["content-type"]).toBe("text/javascript; charset=utf-8"); + const assetBody = yield* asset.text; + expect(assetBody).toBe("export const ready = true;"); - const page = await fetch(`${origin}/studio`); - expect(page.status).toBe(200); - expect(await page.text()).toBe("SSR /studio"); - }); + const page = yield* client.get(`${origin}/studio`); + expect(page.status).toBe(200); + const pageBody = yield* page.text; + expect(pageBody).toBe("SSR /studio"); + }).pipe(Effect.scoped, Effect.provide(testServices)), + )); }); describe("WWW route ownership", () => { diff --git a/apps/cli/build.ts b/apps/cli/build.ts index e0585d706..2870cd720 100644 --- a/apps/cli/build.ts +++ b/apps/cli/build.ts @@ -1,3 +1,6 @@ +import { NodeRuntime } from "@effect/platform-node"; +import { causeMessage } from "@voidhash/lib/lang"; +import { Data, Effect } from "effect"; import * as esbuild from "esbuild"; import * as tsup from "tsup"; @@ -21,31 +24,34 @@ esbuild.buildSync({ target: "node16", }); -const main = async () => { - await tsup.build({ - dts: true, - entryPoints: ["./src/index.ts"], - external: ["esbuild"], - format: ["cjs", "esm"], - outDir: "./dist", - outExtension: (ctx) => { - if (ctx.format === "cjs") { +class BuildFailedError extends Data.TaggedError("BuildFailedError")<{ + readonly message: string; +}> {} + +const main = Effect.tryPromise({ + catch: (cause) => new BuildFailedError({ message: causeMessage(cause) }), + try: () => + tsup.build({ + dts: true, + entryPoints: ["./src/index.ts"], + external: ["esbuild"], + format: ["cjs", "esm"], + outDir: "./dist", + outExtension: (ctx) => { + if (ctx.format === "cjs") { + return { + dts: ".d.ts", + js: ".cjs", + }; + } return { - dts: ".d.ts", - js: ".cjs", + dts: ".d.mts", + js: ".mjs", }; - } - return { - dts: ".d.mts", - js: ".mjs", - }; - }, - splitting: false, - }); -}; - -main().catch((error) => { - // User facing console error. - console.error(error); - process.exit(1); + }, + splitting: false, + }), }); + +// runMain reports the failure and exits with a non-zero code. +NodeRuntime.runMain(main); diff --git a/apps/cli/package.json b/apps/cli/package.json index 1e7999396..d8b2ed3e9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -36,6 +36,7 @@ "@better-auth/api-key": "catalog:", "@effect/platform-node": "4.0.0-beta.100", "@voidhash/generated-clients": "workspace:*", + "@voidhash/lib": "workspace:*", "@voidhash/shared": "workspace:*", "@voidhash/studio": "workspace:*", "better-auth": "catalog:", diff --git a/apps/cli/src/cli/commands/auth-token.ts b/apps/cli/src/cli/commands/auth-token.ts index ba595f36a..c30beb063 100644 --- a/apps/cli/src/cli/commands/auth-token.ts +++ b/apps/cli/src/cli/commands/auth-token.ts @@ -1,4 +1,4 @@ -import { Console, Effect } from "effect"; +import { Config, Console, Effect, Schema } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { CliConfig } from "../../domain/services/cli-config"; @@ -9,15 +9,24 @@ const projectFlag = Flag.string("project").pipe( Flag.withDefault(""), ); +/** The optional project header, omitted when no project is selected. */ +const projectHeader = (project: string | undefined): Record => { + if (project === undefined || project.length === 0) return {}; + return { "X-Voidhash-Project": project }; +}; + /** Builds the JSON object expected from a Claude Code MCP headers helper. */ export const buildMcpHeaders = ( apiKey: string, project: string | undefined, ): Record => ({ Authorization: `Bearer ${apiKey}`, - ...(project === undefined || project.length === 0 ? {} : { "X-Voidhash-Project": project }), + ...projectHeader(project), }); +/** Serializes the MCP headers object to the JSON printed on stdout. */ +const McpHeadersJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); + /** Prints authenticated MCP request headers without exposing them as arguments. */ export const authTokenCommand = Command.make("token", { project: projectFlag }, ({ project }) => Effect.gen(function* authTokenCommand() { @@ -28,10 +37,18 @@ export const authTokenCommand = Command.make("token", { project: projectFlag }, userError("You must be logged in. Run 'voidhash-cli auth login' first."), ); } - const selectedProject = - project.trim() || - process.env.CLAUDE_PLUGIN_OPTION_PROJECT?.trim() || - process.env.VOIDHASH_PROJECT?.trim(); - yield* Console.log(JSON.stringify(buildMcpHeaders(config.api_key, selectedProject))); + const pluginProject = yield* Config.string("CLAUDE_PLUGIN_OPTION_PROJECT").pipe( + Config.withDefault(""), + Effect.orDie, + ); + const envProject = yield* Config.string("VOIDHASH_PROJECT").pipe( + Config.withDefault(""), + Effect.orDie, + ); + const selectedProject = project.trim() || pluginProject.trim() || envProject.trim(); + const headersJson = yield* Schema.encodeEffect(McpHeadersJson)( + buildMcpHeaders(config.api_key, selectedProject), + ).pipe(Effect.orDie); + yield* Console.log(headersJson); }), ).pipe(Command.withDescription("Print MCP connection headers from the current CLI login.")); diff --git a/apps/cli/src/cli/commands/deploy.ts b/apps/cli/src/cli/commands/deploy.ts index 74a6faa99..0007f6103 100644 --- a/apps/cli/src/cli/commands/deploy.ts +++ b/apps/cli/src/cli/commands/deploy.ts @@ -1,6 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { Console, Effect, Path } from "effect"; +import { Config, Console, Effect, FileSystem, Path, Schema } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { type BuildPaywallsResult, buildPaywalls } from "../../domain/services/paywall-build"; import { @@ -10,8 +8,60 @@ import { import { SourceCode } from "../../domain/services/source-code"; import { userError } from "../../utils/error-formatter"; -const formatBytes = (bytes: number): string => - bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`; +const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + return `${(bytes / 1024).toFixed(1)} KB`; +}; + +/** `", N asset(s)"` for a paywall that ships assets, empty otherwise. */ +const assetsSuffix = (count: number): string => { + if (count > 0) return `, ${count} asset(s)`; + return ""; +}; + +/** `", custom panel"` for a component that emitted a panel bundle. */ +const panelSuffix = (panel: unknown): string => { + if (panel) return ", custom panel"; + return ""; +}; + +/** The fields of the resolved `@voidhash/paywalls` package.json we stamp. */ +const PackageJsonSchema = Schema.Struct({ + name: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), +}); + +/** + * Best-effort: the `@voidhash/paywalls` version the bundle was built against, + * resolved from the user's project. The package's exports map does not expose + * ./package.json, so walk up from the resolved entry. Falls back to + * `"unknown"` whenever the package cannot be resolved or read. + */ +const resolveRuntimeVersion = ( + projectRoot: string, +): Effect.Effect => + Effect.gen(function* resolveRuntimeVersion() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const entry = yield* Effect.try({ + try: () => require.resolve("@voidhash/paywalls", { paths: [projectRoot] }), + catch: (cause) => cause, + }); + + for (let dir = path.dirname(entry); dir !== path.dirname(dir); dir = path.dirname(dir)) { + const pkgPath = path.join(dir, "package.json"); + const exists = yield* fs.exists(pkgPath); + if (!exists) continue; + const pkg = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PackageJsonSchema))( + yield* fs.readFileString(pkgPath), + ); + if (pkg.name === "@voidhash/paywalls" && pkg.version !== undefined) { + return pkg.version; + } + } + return "unknown"; + }).pipe(Effect.orElseSucceed(() => "unknown")); const reportBuild = ({ manifest, outDir, manifestPath }: BuildPaywallsResult) => Effect.gen(function* reportBuild() { @@ -26,7 +76,7 @@ const reportBuild = ({ manifest, outDir, manifestPath }: BuildPaywallsResult) => ` • ${paywall.title} (${paywall.id})\n` + ` hash ${paywall.contentHash.slice(0, 12)}\n` + ` bundle ${formatBytes(size)}` + - (paywall.assets.length ? `, ${paywall.assets.length} asset(s)` : ""), + assetsSuffix(paywall.assets.length), ); } for (const component of manifest.components) { @@ -35,7 +85,7 @@ const reportBuild = ({ manifest, outDir, manifestPath }: BuildPaywallsResult) => ` hash ${component.contentHash.slice(0, 12)}\n` + ` runtime ${formatBytes(component.artifacts.runtime.bytes)}, ` + `${component.previews.length} preview(s)` + - (component.artifacts.panel ? ", custom panel" : ""), + panelSuffix(component.artifacts.panel), ); } yield* Console.log(`\n ${manifest.assets.length} asset(s)`); @@ -95,32 +145,12 @@ export const deployCommand = Command.make( ); const projectRoot = path.resolve("."); - const cliVersion = process.env.VOIDHASH_CLI_VERSION ?? "0.0.0"; - - // Best-effort: stamp the @voidhash/paywalls version the bundle was built - // against, resolved from the user's project. The package's exports map - // does not expose ./package.json, so walk up from the resolved entry. - const runtimeVersion = yield* Effect.try({ - try: () => { - const entry = require.resolve("@voidhash/paywalls", { - paths: [projectRoot], - }); - for (let dir = dirname(entry); dir !== dirname(dir); dir = dirname(dir)) { - const pkgPath = join(dir, "package.json"); - if (existsSync(pkgPath)) { - const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { - name?: string; - version?: string; - }; - if (pkg.name === "@voidhash/paywalls" && typeof pkg.version === "string") { - return pkg.version; - } - } - } - throw new Error("@voidhash/paywalls package.json not found"); - }, - catch: (cause) => cause, - }).pipe(Effect.orElseSucceed(() => "unknown")); + const cliVersion = yield* Config.string("VOIDHASH_CLI_VERSION").pipe( + Config.withDefault("0.0.0"), + Effect.orDie, + ); + + const runtimeVersion = yield* resolveRuntimeVersion(projectRoot); yield* Console.log("Building paywalls…"); diff --git a/apps/cli/src/cli/commands/init.ts b/apps/cli/src/cli/commands/init.ts index ede69f50c..c1dcfd62c 100644 --- a/apps/cli/src/cli/commands/init.ts +++ b/apps/cli/src/cli/commands/init.ts @@ -12,6 +12,18 @@ import { assertFileCanBeCreated } from "../../utils/fs"; import { selectOrganization } from "../../utils/organizations/select-organization"; import { selectProject } from "../../utils/projects/select-project"; +/** The config file name matching the project's source language. */ +const configFileNameFor = (language: "ts" | "js"): string => { + if (language === "ts") return "voidhash.config.ts"; + return "voidhash.config.js"; +}; + +/** The scaffolded SDK client file name matching the project's source language. */ +const clientFileNameFor = (language: "ts" | "js"): string => { + if (language === "ts") return "voidhash.ts"; + return "voidhash.js"; +}; + /** * `voidhash-cli init` * @@ -90,12 +102,7 @@ export const initCommand = Command.make("init", {}, () => // Sanity-check that a publishable key exists for this project; we don't // need to write it anywhere (the user puts it in their app code), but a // missing key is a configuration problem we should surface now. - const apiKeys = (yield* apiClient.apiKeysListApiKeys()) as readonly { - id: string; - isPublic: boolean; - projectId: string; - rawKey?: string; - }[]; + const apiKeys = yield* apiClient.apiKeysListApiKeys(); const publishableApiKey = apiKeys.find( (apiKey) => apiKey.isPublic && apiKey.projectId === project.id, ); @@ -109,7 +116,7 @@ export const initCommand = Command.make("init", {}, () => // Decide where the generated `.d.ts` lives. We default to the project // root since module augmentation works from anywhere in `tsconfig.include`. const language = yield* sourceCode.detectSrcLanguage(); - const configFileName = language === "ts" ? "voidhash.config.ts" : "voidhash.config.js"; + const configFileName = configFileNameFor(language); const configFilePath = path.resolve(configFileName); const typesOutputPath = path.resolve(DEFAULT_TYPES_OUTPUT); @@ -117,7 +124,7 @@ export const initCommand = Command.make("init", {}, () => // Scaffold the SDK client into `src/lib` (or `lib` when there's no `src`), // matching the project's `src` layout and language. const srcDir = yield* sourceCode.retrieveSrcDir(); - const clientFileName = language === "ts" ? "voidhash.ts" : "voidhash.js"; + const clientFileName = clientFileNameFor(language); const clientFilePath = path.join(srcDir, "lib", clientFileName); yield* assertFileCanBeCreated(configFileName, configFilePath); diff --git a/apps/cli/src/cli/commands/studio.ts b/apps/cli/src/cli/commands/studio.ts index 881bd6f5b..a7874cfcf 100644 --- a/apps/cli/src/cli/commands/studio.ts +++ b/apps/cli/src/cli/commands/studio.ts @@ -1,44 +1,68 @@ -import { type ChildProcess, spawn } from "node:child_process"; -import { dirname, join } from "node:path"; import { Console, Effect, FileSystem, Path } from "effect"; import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess } from "effect/unstable/process"; import { userError } from "../../utils/error-formatter"; const DEFAULT_PORT = 4830; /** Resolves the installed Studio app directory and the Vite CLI entry point. */ -const resolveStudioPaths = () => - Effect.try({ +const resolveStudioPaths = Effect.gen(function* resolveStudioPaths() { + const path = yield* Path.Path; + return yield* Effect.try({ try: () => { // `require.resolve` works both in the bundled CJS binary and under tsx in // development. We resolve the package manifest to get the app root, and // Vite's own CLI entry so we can launch it without depending on bin // shims being hoisted in any particular way. - const studioDir = dirname(require.resolve("@voidhash/studio/package.json")); + const studioDir = path.dirname(require.resolve("@voidhash/studio/package.json")); // Resolve Vite via its package.json (an exported subpath) from the Studio // package, then join the CLI entry — `vite/bin/vite.js` is not an exported // subpath, so it can't be resolved directly under Node's exports rules. - const viteDir = dirname(require.resolve("vite/package.json", { paths: [studioDir] })); - const viteBin = join(viteDir, "bin", "vite.js"); + const viteDir = path.dirname(require.resolve("vite/package.json", { paths: [studioDir] })); + const viteBin = path.join(viteDir, "bin", "vite.js"); return { studioDir, viteBin }; }, catch: () => userError("Could not locate the Voidhash Studio app. Reinstall the CLI and try again."), }); +}); -/** Best-effort: open the given URL in the user's default browser. */ -const openBrowser = (url: string): void => { - const command = - process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; - const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; - try { - spawn(command, args, { stdio: "ignore", detached: true }).unref(); - } catch { - // Opening the browser is a convenience; never fail the command over it. +/** The platform-specific command that hands a URL to the default browser. */ +const browserOpenCommand = (url: string) => { + if (process.platform === "darwin") { + return ChildProcess.make("open", [url], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + } + if (process.platform === "win32") { + return ChildProcess.make("cmd", ["/c", "start", "", url], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); } + return ChildProcess.make("xdg-open", [url], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); }; +/** Best-effort: open the given URL in the user's default browser. */ +const openBrowser = (url: string) => + Effect.gen(function* openBrowser() { + const child = yield* browserOpenCommand(url); + // Detach the opener so it outlives this command, mirroring `unref()`. + yield* child.unref; + }).pipe( + Effect.scoped, + // Opening the browser is a convenience; never fail the command over it. + Effect.ignore, + ); + /** * `voidhash-cli studio [--port] [--no-open]` * @@ -77,7 +101,7 @@ export const studioCommand = Command.make( ); } - const { studioDir, viteBin } = yield* resolveStudioPaths(); + const { studioDir, viteBin } = yield* resolveStudioPaths; const url = `http://localhost:${port}`; yield* Console.log("\n Voidhash Studio"); @@ -85,31 +109,33 @@ export const studioCommand = Command.make( yield* Console.log(` Preview: ${url}\n`); // Spawn Vite, keep the command alive until the child exits, and ensure the - // child is terminated if the fiber is interrupted (Ctrl+C). - yield* Effect.acquireUseRelease( - Effect.sync(() => - spawn(process.execPath, [viteBin, "--port", String(port), "--strictPort"], { - cwd: studioDir, - env: { ...process.env, VOIDHASH_PROJECT_ROOT: projectRoot }, - stdio: "inherit", - }), - ), - (child: ChildProcess) => { + // child is terminated if the fiber is interrupted (Ctrl+C) — the scope + // finalizer installed by the spawner sends SIGTERM. + yield* Effect.scoped( + Effect.gen(function* runStudio() { + const child = yield* ChildProcess.make( + process.execPath, + [viteBin, "--port", String(port), "--strictPort"], + { + cwd: studioDir, + detached: false, + env: { VOIDHASH_PROJECT_ROOT: projectRoot }, + extendEnv: true, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }, + ); + if (open) { // Give Vite a moment to bind the port before opening the browser. - setTimeout(() => openBrowser(url), 1500); + yield* Effect.forkScoped( + Effect.sleep("1500 millis").pipe(Effect.andThen(openBrowser(url))), + ); } - return Effect.callback((resume) => { - child.on("exit", () => resume(Effect.void)); - child.on("error", (error) => resume(Effect.die(error))); - }); - }, - (child: ChildProcess) => - Effect.sync(() => { - if (child.exitCode === null && !child.killed) { - child.kill("SIGTERM"); - } - }), + + yield* child.exitCode; + }), ); }), ).pipe(Command.withDescription("Launch the paywall preview Studio for this project.")); diff --git a/apps/cli/src/cli/index.ts b/apps/cli/src/cli/index.ts index bf2349d25..79fe0bdc9 100644 --- a/apps/cli/src/cli/index.ts +++ b/apps/cli/src/cli/index.ts @@ -39,9 +39,12 @@ const cli = Command.run(command, { }); // Apply debug log level if --debug flag is present -const cliEffect = cli.pipe( - isDebugMode() ? Effect.provideService(References.MinimumLogLevel, "Debug") : (x) => x, -); +const withDebugLogLevel = (effect: Effect.Effect): Effect.Effect => { + if (!isDebugMode()) return effect; + return effect.pipe(Effect.provideService(References.MinimumLogLevel, "Debug")); +}; + +const cliEffect = withDebugLogLevel(cli); const ServicesLayer = Layer.mergeAll( SourceCode.Default, diff --git a/apps/cli/src/domain/schema/paywall-deploy.ts b/apps/cli/src/domain/schema/paywall-deploy.ts index 8d6319804..189f6997c 100644 --- a/apps/cli/src/domain/schema/paywall-deploy.ts +++ b/apps/cli/src/domain/schema/paywall-deploy.ts @@ -5,10 +5,11 @@ * `docs/specs/paywall-deploy-contract.md` (§1); these schemas mirror it * exactly and MUST stay in sync. Breaking changes bump the schema version. */ +import { constant } from "@voidhash/lib/lang"; import { Schema } from "effect"; /** Current deploy manifest schema version (contract §1). */ -export const DEPLOY_MANIFEST_VERSION = 2 as const; +export const DEPLOY_MANIFEST_VERSION = constant(2); /** Paywall/component slug shape (contract §1.1). */ export const DEPLOY_SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/; @@ -146,10 +147,10 @@ export const DeployManifestSchema = Schema.Struct({ (manifest: { readonly paywalls: ReadonlyArray; readonly components: ReadonlyArray; - }) => - manifest.paywalls.length > 0 || manifest.components.length > 0 - ? undefined - : "manifest must contain at least one paywall or one component", + }) => { + if (manifest.paywalls.length > 0 || manifest.components.length > 0) return undefined; + return "manifest must contain at least one paywall or one component"; + }, ), ); export type DeployManifest = typeof DeployManifestSchema.Type; diff --git a/apps/cli/src/domain/services/auth.ts b/apps/cli/src/domain/services/auth.ts index 21b8fdf30..6fba1af61 100644 --- a/apps/cli/src/domain/services/auth.ts +++ b/apps/cli/src/domain/services/auth.ts @@ -1,11 +1,10 @@ import { NodeServices, NodeHttpServer } from "@effect/platform-node"; import type { AuthSession200 } from "@voidhash/generated-clients"; -import { Console, Data, Effect, Layer, PubSub, Context } from "effect"; +import { Console, Data, Effect, Layer, Option, PubSub, Context } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { ChildProcess } from "effect/unstable/process"; import { customAlphabet } from "nanoid"; -import { spawn } from "node:child_process"; import { createServer } from "node:http"; -import url from "node:url"; import { CONFIG_FILE_NAME } from "../../constants"; import { ApiClient } from "../../utils/api-client"; @@ -66,6 +65,16 @@ const hasNestedTag = ( typeof error.data._tag === "string" && error.data._tag === innerTag; +/** + * Best-effort: hand the confirmation URL to the user's default browser. The + * opener is detached so it outlives the login command, and never fails it. + */ +const openBrowser = (url: string) => + Effect.gen(function* openBrowser() { + const child = yield* ChildProcess.make("open", [url]); + yield* child.unref; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.ignore); + const isNoSignedInUserError = (error: unknown): error is NoSignedInUserError => error instanceof NoSignedInUserError || hasTag(error, "NoSignedInUserError"); @@ -80,10 +89,12 @@ const runCallbackServer = (callbackEvents: PubSub.PubSub) => "/callback", Effect.gen(function* CallbackRoute() { const req = yield* HttpServerRequest.HttpServerRequest; - const parsedUrl = url.parse(req.url as string, true); - const { query } = parsedUrl; + const query = Option.match(HttpServerRequest.toURL(req), { + onNone: () => new URLSearchParams(), + onSome: (requestUrl) => requestUrl.searchParams, + }); - if (query.cancelled) { + if (query.get("cancelled")) { yield* PubSub.publish(callbackEvents, { type: "cancelled" }); return HttpServerResponse.text("Login cancelled").pipe( HttpServerResponse.setHeader("Access-Control-Allow-Origin", "*"), @@ -92,8 +103,8 @@ const runCallbackServer = (callbackEvents: PubSub.PubSub) => } yield* PubSub.publish(callbackEvents, { - code: query.code as string, - key: query.key as string, + code: query.get("code") ?? "", + key: query.get("key") ?? "", type: "success", }); return HttpServerResponse.text("Login successful").pipe( @@ -182,10 +193,11 @@ const make = Effect.gen(function* effect() { // Launch the callback server in a separate fiber to avoid blocking yield* Effect.logDebug(`Starting callback server on ${host}:${port}`); yield* Effect.forkChild( - Effect.catch(runCallbackServer(callbackEventsPubSub), (error) => { - console.log(error); - return Effect.die(error); - }), + Effect.catch(runCallbackServer(callbackEventsPubSub), (error) => + Effect.logError(`Callback server failed: ${String(error)}`).pipe( + Effect.andThen(Effect.die(error)), + ), + ), ); // Set up the application server with routing @@ -204,7 +216,7 @@ const make = Effect.gen(function* effect() { yield* Console.log( `If something goes wrong, copy and paste this URL into your browser: ${confirmationUrl.toString()}\n`, ); - spawn("open", [confirmationUrl.toString()]); + yield* openBrowser(confirmationUrl.toString()); // Wait for the callback event yield* Effect.logDebug("Waiting for callback from browser"); diff --git a/apps/cli/src/domain/services/cli-config.ts b/apps/cli/src/domain/services/cli-config.ts index 2827d76a7..fd3b8b655 100644 --- a/apps/cli/src/domain/services/cli-config.ts +++ b/apps/cli/src/domain/services/cli-config.ts @@ -1,3 +1,4 @@ +import { constant } from "@voidhash/lib/lang"; import { Effect, FileSystem, Layer, Path, Schema, Context } from "effect"; import os from "node:os"; @@ -30,6 +31,21 @@ const baseOf = (config: ConfigFile): ResolvedConfig => ({ web_url: config.web_url, }); +/** + * Builds the profile overrides to keep when resetting a profile. Never persists + * `api_key: null` as an override — that would mask the base key. + */ +const preservedOverrides = (apiKey: string | null | undefined): ProfileOverrides => { + if (apiKey) return { api_key: apiKey }; + return {}; +}; + +/** Raw JSON object shape of the config file, before schema decoding. */ +const RawConfigJsonSchema = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); + +/** Encodes a validated config file to the JSON text written to disk. */ +const ConfigFileJsonSchema = Schema.fromJsonString(CliConfigSchema); + const make = Effect.gen(function* effect() { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -56,7 +72,7 @@ const make = Effect.gen(function* effect() { return yield* Effect.succeed(emptyConfig); } const configString = yield* fileSystem.readFileString(filePath); - const configJson = JSON.parse(configString); + const configJson = yield* Schema.decodeUnknownEffect(RawConfigJsonSchema)(configString); yield* Effect.logDebug("Config file loaded successfully"); return yield* Schema.decodeUnknownEffect(CliConfigSchema)({ ...emptyConfig, @@ -106,6 +122,23 @@ const make = Effect.gen(function* effect() { * @param config - Partial configuration values to persist. * @returns An Effect that writes the merged configuration to disk. */ + const mergeIntoConfig = ( + currentConfig: ConfigFile, + config: Partial, + ): ConfigFile => { + if (!activeProfile) return { ...currentConfig, ...config }; + return { + ...currentConfig, + profiles: { + ...currentConfig.profiles, + [activeProfile]: { + ...currentConfig.profiles?.[activeProfile], + ...config, + }, + }, + }; + }; + const writeToConfig = (config: Partial) => Effect.gen(function* writeToConfig() { yield* Effect.logDebug(`Writing config to ${filePath}`); @@ -113,21 +146,10 @@ const make = Effect.gen(function* effect() { Effect.catch(() => Effect.succeed(emptyConfig)), ); - const mergedConfig: ConfigFile = activeProfile - ? { - ...currentConfig, - profiles: { - ...currentConfig.profiles, - [activeProfile]: { - ...currentConfig.profiles?.[activeProfile], - ...config, - }, - }, - } - : { ...currentConfig, ...config }; - - const validatedConfig = yield* Schema.decodeUnknownEffect(CliConfigSchema)(mergedConfig); - yield* fileSystem.writeFileString(filePath, JSON.stringify(validatedConfig)); + const mergedConfig = mergeIntoConfig(currentConfig, config); + + const configJson = yield* Schema.encodeEffect(ConfigFileJsonSchema)(mergedConfig); + yield* fileSystem.writeFileString(filePath, configJson); yield* Effect.logDebug("Config file written successfully"); }).pipe(Effect.withSpan("CliConfig.writeToConfig")); @@ -145,16 +167,13 @@ const make = Effect.gen(function* effect() { if (activeProfile) { const raw = yield* readRawConfig(); - const apiKey = raw.profiles?.[activeProfile]?.api_key; - // Never persist `api_key: null` as an override — that would mask the - // base key. Keep only a real, non-null profile key. - const preserved: ProfileOverrides = apiKey ? { api_key: apiKey } : {}; + const preserved = preservedOverrides(raw.profiles?.[activeProfile]?.api_key); const mergedConfig: ConfigFile = { ...baseOf(raw), profiles: { ...raw.profiles, [activeProfile]: preserved }, }; - const validatedConfig = yield* Schema.decodeUnknownEffect(CliConfigSchema)(mergedConfig); - yield* fileSystem.writeFileString(filePath, JSON.stringify(validatedConfig)); + const configJson = yield* Schema.encodeEffect(ConfigFileJsonSchema)(mergedConfig); + yield* fileSystem.writeFileString(filePath, configJson); yield* Effect.logDebug("Config reset complete"); return; } @@ -167,12 +186,12 @@ const make = Effect.gen(function* effect() { yield* Effect.logDebug("Config reset complete"); }).pipe(Effect.withSpan("CliConfig.resetConfig")); - return { + return constant({ readConfig, readRawConfig, resetConfig, writeToConfig, - } as const; + }); }); type CliConfigShape = Effect.Success; diff --git a/apps/cli/src/domain/services/codegen.ts b/apps/cli/src/domain/services/codegen.ts index 09d8f08f9..2e02c8986 100644 --- a/apps/cli/src/domain/services/codegen.ts +++ b/apps/cli/src/domain/services/codegen.ts @@ -1,4 +1,5 @@ -import { Effect, FileSystem, Layer, Path, Context } from "effect"; +import { constant } from "@voidhash/lib/lang"; +import { DateTime, Effect, FileSystem, Layer, Path, Schema, Context } from "effect"; import { VOIDHASH_FETCHED_AT_COMMENT_PREFIX, @@ -9,11 +10,14 @@ import type { Writable } from "../../utils/types"; import type { NormalizedSchema } from "../schema/normalized-schema"; import type { VoidhashConfigSchema } from "../schema/voidhash-config"; +/** Encodes a slug as a quoted TypeScript string literal. */ +const toStringLiteral = Schema.encodeSync(Schema.fromJsonString(Schema.String)); + function toUnionType(slugs: string[]): string { if (slugs.length === 0) { return "never"; } - return slugs.map((slug) => JSON.stringify(slug)).join(" | "); + return slugs.map((slug) => toStringLiteral(slug)).join(" | "); } /** @@ -33,9 +37,9 @@ function toUnionType(slugs: string[]): string { export function generateTypesDeclaration( schema: NormalizedSchema, version: string, - options: { fetchedAt?: Date } = {}, + options: { fetchedAt: Date }, ): string { - const fetchedAt = (options.fetchedAt ?? new Date()).toISOString(); + const fetchedAt = options.fetchedAt.toISOString(); const productSlugs = [...schema.products.keys()].sort(); const locationSlugs = [...schema.locations.keys()].sort(); @@ -126,7 +130,8 @@ const make = Effect.gen(function* effect() { version: string, ) => Effect.gen(function* generateTypesDeclarationFile() { - const content = generateTypesDeclaration(schema, version); + const fetchedAt = yield* DateTime.nowAsDate; + const content = generateTypesDeclaration(schema, version, { fetchedAt }); yield* fileSystem.writeFileString(filePath, content); return version; }); @@ -137,12 +142,12 @@ const make = Effect.gen(function* effect() { return parseVersionFromDeclaration(content); }); - return { + return constant({ generateClientFile, generateTypesDeclarationFile, generateVoidhashConfigFile, readDeclarationVersion, - } as const; + }); }); type CodegenShape = Effect.Success; diff --git a/apps/cli/src/domain/services/paywall-build.ts b/apps/cli/src/domain/services/paywall-build.ts index 67d108e93..d95166d4d 100644 --- a/apps/cli/src/domain/services/paywall-build.ts +++ b/apps/cli/src/domain/services/paywall-build.ts @@ -6,9 +6,19 @@ * schemaVersion-2 deploy manifest (contract: docs/specs/paywall-deploy-contract.md). */ import { createHash } from "node:crypto"; -import { existsSync, promises as fsp, readdirSync, statSync } from "node:fs"; -import { basename, dirname, extname, join, posix, relative, sep } from "node:path"; -import { Data, Effect, Schema } from "effect"; + +import { causeMessage } from "@voidhash/lib/lang"; +import { + Data, + DateTime, + Effect, + FileSystem, + Path, + type PlatformError, + Schema, + SchemaGetter, + SchemaTransformation, +} from "effect"; import * as esbuild from "esbuild"; import { @@ -33,10 +43,13 @@ export class PaywallBuildError extends Data.TaggedError("PaywallBuildError")<{ }> {} /** Directory (relative to the project root) where build output is written. */ -export const BUILD_DIR = join(".voidhash", ".build"); +export const BUILD_DIR = ".voidhash/.build"; const SOURCE_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"]; +/** Separator of the POSIX paths the manifest records. */ +const POSIX_SEP = "/"; + /** * Binary asset types paywall bundles may import; emitted as files. Derived * from the typecheck gate's extension list so the two can never drift. @@ -71,6 +84,42 @@ const CONTENT_TYPES: Record = { const textEncoder = new TextEncoder(); +/** JSON text codec used wherever the build embeds JSON in generated source. */ +const JsonText = Schema.UnknownFromJsonString; + +/** + * JSON text codec for on-disk build artifacts. `space: 2` keeps the emitted + * `manifest.json` / preview trees human-readable, as they were before. + */ +const PrettyJsonText = Schema.String.pipe( + Schema.decodeTo( + Schema.Unknown, + new SchemaTransformation.Transformation( + SchemaGetter.parseJson(), + SchemaGetter.stringifyJson({ space: 2 }), + ), + ), +); + +/** Serializes a value to JSON text, failing instead of throwing. */ +const toJsonText = (subject: string, value: unknown): Effect.Effect => + Schema.encodeEffect(JsonText)(value).pipe( + Effect.mapError( + (cause) => + new PaywallBuildError({ cause, message: `Failed to serialize ${subject}: ${cause.message}` }), + ), + ); + +/** Serializes a value to the JSON bytes written as a build artifact. */ +const toJsonBytes = (subject: string, value: unknown): Effect.Effect => + Schema.encodeEffect(PrettyJsonText)(value).pipe( + Effect.mapError( + (cause) => + new PaywallBuildError({ cause, message: `Failed to serialize ${subject}: ${cause.message}` }), + ), + Effect.map((json) => textEncoder.encode(`${json}\n`)), + ); + /** Lowercase hex SHA-256 of a string or byte payload. */ export const sha256Hex = (data: Uint8Array | string): string => createHash("sha256").update(data).digest("hex"); @@ -103,12 +152,12 @@ export const computeComponentContentHash = (input: { }:${[...input.previewSha256s].sort().join(":")}`, ); -const contentTypeFor = (path: string): string => - CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream"; +const contentTypeFor = (path: Path.Path, file: string): string => + CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream"; /** Normalizes an absolute path to a project-root-relative POSIX path. */ -const toRelPosix = (projectRoot: string, abs: string): string => - relative(projectRoot, abs).split(sep).join(posix.sep); +const toRelPosix = (path: Path.Path, projectRoot: string, abs: string): string => + path.relative(projectRoot, abs).split(path.sep).join(POSIX_SEP); // Discovery (isSourceFile / idFromFile / listFilesRecursive) is mirrored by // Studio's virtual-paywalls plugin @@ -116,52 +165,110 @@ const toRelPosix = (projectRoot: string, abs: string): string => const isSourceFile = (name: string): boolean => SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext)) && !name.endsWith(".d.ts"); -const idFromFile = (file: string): string => basename(file).replace(/\.(tsx|jsx|ts|js)$/, ""); +const idFromFile = (path: Path.Path, file: string): string => + path.basename(file).replace(/\.(tsx|jsx|ts|js)$/, ""); /** Recursively lists files under a directory (absolute paths). */ -const listFilesRecursive = (dir: string): string[] => { - if (!existsSync(dir)) return []; - const out: string[] = []; - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - if (statSync(full).isDirectory()) { - out.push(...listFilesRecursive(full)); - } else { +const listFilesRecursive: ( + fs: FileSystem.FileSystem, + path: Path.Path, + dir: string, +) => Effect.Effect, PlatformError.PlatformError> = (fs, path, dir) => + Effect.gen(function* listDirectory() { + const exists = yield* fs.exists(dir); + if (!exists) return []; + const out: Array = []; + for (const entry of yield* fs.readDirectory(dir)) { + const full = path.join(dir, entry); + const info = yield* fs.stat(full); + if (info.type === "Directory") { + out.push(...(yield* listFilesRecursive(fs, path, full))); + continue; + } out.push(full); } - } - return out; -}; + return out; + }); -const listSourceFiles = (dir: string): string[] => - listFilesRecursive(dir).filter((f) => isSourceFile(basename(f))); +const listSourceFiles = ( + fs: FileSystem.FileSystem, + path: Path.Path, + dir: string, +): Effect.Effect, PaywallBuildError> => + listFilesRecursive(fs, path, dir).pipe( + Effect.map((files) => files.filter((f) => isSourceFile(path.basename(f)))), + Effect.mapError((cause) => new PaywallBuildError({ cause, message: `Failed to scan ${dir}` })), + ); /** Turns an esbuild failure into a readable, file-located error message. */ const describeEsbuildFailure = (cause: unknown): string | undefined => { - if ( - typeof cause === "object" && - cause !== null && - "errors" in cause && - Array.isArray((cause as esbuild.BuildFailure).errors) - ) { - return (cause as esbuild.BuildFailure).errors - .map((error) => { - const location = error.location - ? `${error.location.file}:${error.location.line}:${error.location.column}: ` - : ""; - return ` ${location}${error.text}`; - }) - .join("\n"); + if (typeof cause !== "object" || cause === null || !("errors" in cause)) { + return; } - return; + const errors = cause.errors; + if (!Array.isArray(errors)) { + return; + } + return errors + .map((error: esbuild.Message) => { + if (error.location) { + return ` ${error.location.file}:${error.location.line}:${error.location.column}: ${error.text}`; + } + return ` ${error.text}`; + }) + .join("\n"); }; const bundleFailure = (subject: string) => (cause: unknown) => { const details = describeEsbuildFailure(cause); - return new PaywallBuildError({ - cause, - message: details ? `Failed to bundle ${subject}:\n${details}` : `Failed to bundle ${subject}`, - }); + if (details) { + return new PaywallBuildError({ cause, message: `Failed to bundle ${subject}:\n${details}` }); + } + return new PaywallBuildError({ cause, message: `Failed to bundle ${subject}` }); +}; + +// ── Untyped module reading ─────────────────────────────────────────────────── +// +// Paywall/component modules are user code loaded at runtime, so every property +// read off them goes through these guards rather than a type assertion. + +const readProperty = (value: unknown, key: string): unknown => { + if (typeof value !== "object" || value === null) return undefined; + if (!(key in value)) return undefined; + return Reflect.get(value, key); +}; + +const entriesOf = (value: unknown): Array<[string, unknown]> => { + if (typeof value !== "object" || value === null) return []; + return Object.entries(value); +}; + +const recordOf = (value: unknown): Record => Object.fromEntries(entriesOf(value)); + +const readOptionalString = (value: unknown): string | undefined => { + if (typeof value === "string") return value; + return undefined; +}; + +const readNonEmptyString = (value: unknown): string | undefined => { + if (typeof value === "string" && value.length > 0) return value; + return undefined; +}; + +const readStringArray = (value: unknown): Array => { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +}; + +const readArray = (value: unknown): ReadonlyArray => { + if (Array.isArray(value)) return value; + return []; +}; + +/** `": "` for an `Error` cause, a bare `"."` otherwise. */ +const manifestFailureSuffix = (cause: unknown): string => { + if (cause instanceof Error) return `: ${cause.message}`; + return "."; }; // ── User-project library access ────────────────────────────────────────────── @@ -183,9 +290,9 @@ interface UserTreeLib { readonly config?: { readonly products?: ReadonlyArray; readonly variables?: Record; - readonly platform?: "ios" | "android" | "web"; - readonly safeAreaInsets?: ComponentPreviewSafeAreaInsets; - readonly dimensions?: ComponentPreviewDimensions; + readonly platform?: unknown; + readonly safeAreaInsets?: unknown; + readonly dimensions?: unknown; }; readonly state?: string; }, @@ -196,55 +303,25 @@ interface UserReactLib { readonly createElement: (type: unknown, props: Record | null) => unknown; } -interface ComponentPreviewStateLike { - readonly props?: Record; - readonly data?: { - readonly products?: ReadonlyArray; - readonly variables?: Record; - readonly platform?: "ios" | "android" | "web"; - readonly safeAreaInsets?: ComponentPreviewSafeAreaInsets; - readonly dimensions?: ComponentPreviewDimensions; - }; -} - -interface ComponentPreviewSafeAreaInsets { - readonly top: number; - readonly right: number; - readonly bottom: number; - readonly left: number; -} - -interface ComponentPreviewDimensions { - readonly screen: { - readonly width: number; - readonly height: number; - readonly x: number; - readonly y: number; - }; - readonly window: { - readonly width: number; - readonly height: number; - readonly x: number; - readonly y: number; - }; -} - -interface ComponentDefinitionLike { +type ComponentDefinitionLike = Record & { readonly id: string; readonly title?: string; readonly description?: string; - readonly previews: Record; + readonly previews: Record; readonly panel?: unknown; readonly component: unknown; readonly __voidhash: { readonly kind: string }; -} +}; const requireFromProject = ( projectRoot: string, specifier: string, ): Effect.Effect => Effect.try({ - try: () => require(require.resolve(specifier, { paths: [projectRoot] })) as T, + try: () => { + const loaded: T = require(require.resolve(specifier, { paths: [projectRoot] })); + return loaded; + }, catch: (cause) => new PaywallBuildError({ cause, @@ -260,12 +337,12 @@ const requireFromProject = ( * and preview rendering. The shared `safeRegister` helper uses the `ts` * loader, which rejects JSX — hence a dedicated hook here. */ +const loadEsbuildRegister = () => import("esbuild-register/dist/node"); + const registerTsxLoader = (): Effect.Effect<{ unregister: () => void }, PaywallBuildError> => Effect.tryPromise({ - try: async () => { - const { register } = await import("esbuild-register/dist/node"); - return register({ format: "cjs", loader: "tsx" }); - }, + try: () => + loadEsbuildRegister().then(({ register }) => register({ format: "cjs", loader: "tsx" })), catch: (cause) => new PaywallBuildError({ cause, @@ -277,15 +354,13 @@ const loadModuleDefault = (file: string): Effect.Effect { delete require.cache[require.resolve(file)]; - const mod = require(file) as { default?: unknown }; + const mod: { default?: unknown } = require(file); return mod?.default ?? mod; }, catch: (cause) => new PaywallBuildError({ cause, - message: `Failed to load ${file}: ${ - cause instanceof Error ? cause.message : String(cause) - }`, + message: `Failed to load ${file}: ${causeMessage(cause)}`, }), }); @@ -300,34 +375,30 @@ interface PaywallModuleMeta { } /** Reads the `__voidhash` metadata off a paywall module's default export. */ -const loadPaywallMeta = (file: string): Effect.Effect => +const loadPaywallMeta = ( + path: Path.Path, + file: string, +): Effect.Effect => loadModuleDefault(file).pipe( Effect.flatMap((def) => { - const meta = (def as { __voidhash?: Record } | null | undefined)?.__voidhash; - if (!meta || meta.kind !== "paywall") { + const meta = readProperty(def, "__voidhash"); + if (meta === undefined || readProperty(meta, "kind") !== "paywall") { return Effect.fail( new PaywallBuildError({ message: `${file} must default-export createPaywall({ … }) from "@voidhash/paywalls".`, }), ); } - const title = - typeof meta.title === "string" && meta.title.length > 0 ? meta.title : idFromFile(file); - const description = typeof meta.description === "string" ? meta.description : undefined; - const products = Array.isArray(meta.products) - ? meta.products.filter((p): p is string => typeof p === "string") - : []; - const rawVariables = - typeof meta.variables === "object" && meta.variables !== null - ? (meta.variables as Record) - : {}; + const title = readNonEmptyString(readProperty(meta, "title")) ?? idFromFile(path, file); + const description = readOptionalString(readProperty(meta, "description")); + const products = readStringArray(readProperty(meta, "products")); const variables: Record = {}; - for (const [key, value] of Object.entries(rawVariables)) { + for (const [key, value] of entriesOf(readProperty(meta, "variables"))) { if (!isScalar(value)) { return Effect.fail( new PaywallBuildError({ message: - `Variable "${key}" of paywall ${basename(file)} must be a ` + + `Variable "${key}" of paywall ${path.basename(file)} must be a ` + "string, number or boolean (contract §1.1).", }), ); @@ -345,81 +416,90 @@ const loadPaywallMeta = (file: string): Effect.Effect => loadModuleDefault(file).pipe( Effect.flatMap((def) => { - const candidate = def as Partial | null; - if ( - !candidate || - candidate.__voidhash?.kind !== "component" || - typeof candidate.component !== "function" || - typeof candidate.id !== "string" - ) { + const kind = readProperty(readProperty(def, "__voidhash"), "kind"); + const component = readProperty(def, "component"); + const id = readProperty(def, "id"); + if (kind !== "component" || typeof component !== "function" || typeof id !== "string") { return Effect.fail( new PaywallBuildError({ message: `${file} must default-export defineComponent({ … }) from "@voidhash/paywalls".`, }), ); } - const expectedId = idFromFile(file); - if (candidate.id !== expectedId) { + const expectedId = idFromFile(path, file); + if (id !== expectedId) { return Effect.fail( new PaywallBuildError({ message: - `Component id "${candidate.id}" does not match its file name ` + - `"${expectedId}" (${basename(file)}). Rename the file or the id.`, + `Component id "${id}" does not match its file name ` + + `"${expectedId}" (${path.basename(file)}). Rename the file or the id.`, }), ); } - return Effect.succeed({ - ...candidate, - previews: candidate.previews ?? {}, - } as ComponentDefinitionLike); + // The whole default export is handed to `extractComponentManifest`, so + // every own property is carried over, not just the ones read here. + return Effect.succeed({ + ...recordOf(def), + __voidhash: { kind }, + component, + description: readOptionalString(readProperty(def, "description")), + id, + panel: readProperty(def, "panel"), + previews: recordOf(readProperty(def, "previews")), + title: readOptionalString(readProperty(def, "title")), + }); }), ); // ── Output writing ─────────────────────────────────────────────────────────── -const writeFile = (absPath: string, bytes: Uint8Array): Effect.Effect => - Effect.tryPromise({ - try: async () => { - await fsp.mkdir(dirname(absPath), { recursive: true }); - await fsp.writeFile(absPath, bytes); - }, - catch: (cause) => new PaywallBuildError({ cause, message: `Failed to write ${absPath}` }), - }); +const writeFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + absPath: string, + bytes: Uint8Array, +): Effect.Effect => + fs.makeDirectory(path.dirname(absPath), { recursive: true }).pipe( + Effect.andThen(() => fs.writeFile(absPath, bytes)), + Effect.mapError((cause) => new PaywallBuildError({ cause, message: `Failed to write ${absPath}` })), + ); /** Writes `bytes` to `absPath` and returns its manifest artifact entry. */ const writeArtifact = ( + fs: FileSystem.FileSystem, + path: Path.Path, projectRoot: string, absPath: string, bytes: Uint8Array, ): Effect.Effect => - writeFile(absPath, bytes).pipe( + writeFile(fs, path, absPath, bytes).pipe( Effect.map(() => ({ bytes: bytes.byteLength, - contentType: contentTypeFor(absPath), - path: toRelPosix(projectRoot, absPath), + contentType: contentTypeFor(path, absPath), + path: toRelPosix(path, projectRoot, absPath), sha256: sha256Hex(bytes), })), ); const readDeployFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, projectRoot: string, absPath: string, ): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const bytes = await fsp.readFile(absPath); - return { - bytes: bytes.byteLength, - path: toRelPosix(projectRoot, absPath), - sha256: sha256Hex(bytes), - }; - }, - catch: (cause) => new PaywallBuildError({ cause, message: `Failed to read ${absPath}` }), - }); + fs.readFile(absPath).pipe( + Effect.map((bytes) => ({ + bytes: bytes.byteLength, + path: toRelPosix(path, projectRoot, absPath), + sha256: sha256Hex(bytes), + })), + Effect.mapError((cause) => new PaywallBuildError({ cause, message: `Failed to read ${absPath}` })), + ); // ── Paywall bundling ───────────────────────────────────────────────────────── @@ -460,8 +540,8 @@ const htmlShell = (jsFileName: string): string => `; /** The in-memory entry esbuild bundles for a paywall. */ -const paywallEntryContents = (paywallAbsPath: string): string => - `import paywall from ${JSON.stringify(paywallAbsPath)}; +const paywallEntryContents = (paywallModuleSpecifier: string): string => + `import paywall from ${paywallModuleSpecifier}; import { mountPaywall } from "@voidhash/paywalls/dom"; const root = document.getElementById("root"); if (root) mountPaywall(paywall, root); @@ -474,66 +554,78 @@ interface BuiltPaywallArtifacts { readonly assets: ReadonlyArray<{ relName: string; bytes: Uint8Array }>; } +/** The `assets/…` name an emitted esbuild output file keeps in the bundle. */ +const assetRelName = (rel: string): string => { + const idx = rel.indexOf(`${POSIX_SEP}assets${POSIX_SEP}`); + if (idx >= 0) return rel.slice(idx + 1); + return rel.slice(rel.lastIndexOf(POSIX_SEP) + 1); +}; + /** Bundles a single paywall to HTML + JS (+ assets) in memory via esbuild. */ const bundlePaywall = ( + path: Path.Path, projectRoot: string, voidhashDir: string, paywallAbsPath: string, ): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const result = await esbuild.build({ - assetNames: "assets/[name]-[hash]", - bundle: true, - define: { "process.env.NODE_ENV": '"production"' }, - format: "iife", - jsx: "automatic", - jsxImportSource: "react", - loader: PAYWALL_ASSET_LOADERS, - logLevel: "silent", - minify: true, - outdir: "out", - platform: "browser", - plugins: [closedImportsPlugin(voidhashDir)], - publicPath: ".", - stdin: { - contents: paywallEntryContents(paywallAbsPath), - loader: "tsx", - resolveDir: projectRoot, - sourcefile: "voidhash-entry.tsx", - }, - target: ["es2019", "safari13"], - write: false, - }); + Effect.gen(function* bundlePaywall() { + const subject = `paywall ${path.basename(paywallAbsPath)}`; + const specifier = yield* toJsonText("the paywall entry point", paywallAbsPath); + + const result = yield* Effect.tryPromise({ + try: () => + esbuild.build({ + assetNames: "assets/[name]-[hash]", + bundle: true, + define: { "process.env.NODE_ENV": '"production"' }, + format: "iife", + jsx: "automatic", + jsxImportSource: "react", + loader: PAYWALL_ASSET_LOADERS, + logLevel: "silent", + minify: true, + outdir: "out", + platform: "browser", + plugins: [closedImportsPlugin(voidhashDir)], + publicPath: ".", + stdin: { + contents: paywallEntryContents(specifier), + loader: "tsx", + resolveDir: projectRoot, + sourcefile: "voidhash-entry.tsx", + }, + target: ["es2019", "safari13"], + write: false, + }), + catch: bundleFailure(subject), + }); - let jsBytes: Uint8Array | undefined; - const assets: Array<{ relName: string; bytes: Uint8Array }> = []; - - for (const file of result.outputFiles) { - const rel = file.path.split(sep).join(posix.sep); - if (rel.endsWith(".js")) { - jsBytes = file.contents; - } else { - // Asset emitted under out/assets/… — keep the assets/… suffix. - const idx = rel.indexOf("/assets/"); - const relName = idx >= 0 ? rel.slice(idx + 1) : posix.basename(rel); - assets.push({ bytes: file.contents, relName }); - } - } + let jsBytes: Uint8Array | undefined; + const assets: Array<{ relName: string; bytes: Uint8Array }> = []; - if (!jsBytes) { - throw new Error("esbuild produced no JavaScript output"); + for (const file of result.outputFiles) { + const rel = file.path.split(path.sep).join(POSIX_SEP); + if (rel.endsWith(".js")) { + jsBytes = file.contents; + continue; } + // Asset emitted under out/assets/… — keep the assets/… suffix. + assets.push({ bytes: file.contents, relName: assetRelName(rel) }); + } - const jsFileName = "bundle.js"; - return { - assets, - htmlBytes: textEncoder.encode(htmlShell(jsFileName)), - jsBytes, - jsFileName, - }; - }, - catch: bundleFailure(`paywall ${basename(paywallAbsPath)}`), + if (!jsBytes) { + return yield* new PaywallBuildError({ + message: `Failed to bundle ${subject}: esbuild produced no JavaScript output`, + }); + } + + const jsFileName = "bundle.js"; + return { + assets, + htmlBytes: textEncoder.encode(htmlShell(jsFileName)), + jsBytes, + jsFileName, + }; }); // ── Component bundling ─────────────────────────────────────────────────────── @@ -619,29 +711,38 @@ const panelBuildOptions = (voidhashDir: string): esbuild.BuildOptions => ({ export const definitionHasPanel = (definition: { readonly panel?: unknown }): boolean => typeof definition.panel === "function"; -const firstJsOutput = (result: esbuild.BuildResult): Uint8Array => { - const file = (result.outputFiles ?? []).find((f) => f.path.endsWith(".js")); - if (!file) { - throw new Error("esbuild produced no JavaScript output"); - } - return file.contents; -}; +const firstJsOutput = (result: esbuild.BuildResult): Uint8Array | undefined => + (result.outputFiles ?? []).find((f) => f.path.endsWith(".js"))?.contents; + +/** Runs an esbuild bundle and returns its single JavaScript output. */ +const bundleSingleJs = ( + subject: string, + options: esbuild.BuildOptions, +): Effect.Effect => + Effect.gen(function* bundleSingleJs() { + const result = yield* Effect.tryPromise({ + try: () => esbuild.build(options), + catch: bundleFailure(subject), + }); + const bytes = firstJsOutput(result); + if (bytes === undefined) { + return yield* new PaywallBuildError({ + message: `Failed to bundle ${subject}: esbuild produced no JavaScript output`, + }); + } + return bytes; + }); /** Bundles a component module to a single ESM `runtime.js`. */ const bundleComponentRuntime = ( + path: Path.Path, voidhashDir: string, componentAbsPath: string, ): Effect.Effect => - Effect.tryPromise({ - try: async () => - firstJsOutput( - await esbuild.build({ - ...componentBuildOptions(voidhashDir), - entryPoints: [componentAbsPath], - outdir: "out", - }), - ), - catch: bundleFailure(`component ${basename(componentAbsPath)}`), + bundleSingleJs(`component ${path.basename(componentAbsPath)}`, { + ...componentBuildOptions(voidhashDir), + entryPoints: [componentAbsPath], + outdir: "out", }); /** @@ -655,19 +756,14 @@ const bundleComponentRuntime = ( * externals) so the byte output matches the sandbox's require shim exactly. */ const bundleComponentPanel = ( + path: Path.Path, voidhashDir: string, componentAbsPath: string, ): Effect.Effect => - Effect.tryPromise({ - try: async () => - firstJsOutput( - await esbuild.build({ - ...panelBuildOptions(voidhashDir), - entryPoints: [componentAbsPath], - outdir: "out", - }), - ), - catch: bundleFailure(`panel of component ${basename(componentAbsPath)}`), + bundleSingleJs(`panel of component ${path.basename(componentAbsPath)}`, { + ...panelBuildOptions(voidhashDir), + entryPoints: [componentAbsPath], + outdir: "out", }); // ── Preview tree inspection ────────────────────────────────────────────────── @@ -712,18 +808,19 @@ export const collectRenderErrorPlaceholderReasons = (tree: unknown): string[] => // ── Validation helpers ─────────────────────────────────────────────────────── const validateIds = ( + path: Path.Path, kind: "paywall" | "component", files: ReadonlyArray, ): Effect.Effect => Effect.gen(function* validateIds() { const seen = new Map(); for (const file of files) { - const id = idFromFile(file); + const id = idFromFile(path, file); if (!DEPLOY_SLUG_REGEX.test(id)) { return yield* Effect.fail( new PaywallBuildError({ message: - `Invalid ${kind} id "${id}" (${basename(file)}). Ids derive ` + + `Invalid ${kind} id "${id}" (${path.basename(file)}). Ids derive ` + `from file names and must match ${DEPLOY_SLUG_REGEX}.`, }), ); @@ -782,16 +879,26 @@ export const buildPaywalls = ({ cliVersion, runtimeVersion, onWarn, -}: BuildPaywallsOptions): Effect.Effect => +}: BuildPaywallsOptions): Effect.Effect< + BuildPaywallsResult, + PaywallBuildError, + FileSystem.FileSystem | Path.Path +> => Effect.gen(function* buildPaywalls() { - const warn = (message: string): Effect.Effect => (onWarn ? onWarn(message) : Effect.void); - const voidhashDir = join(projectRoot, ".voidhash"); - const paywallsDir = join(voidhashDir, "paywalls"); - const componentsDir = join(voidhashDir, "components"); - const outDir = join(projectRoot, BUILD_DIR); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - const paywallFiles = listSourceFiles(paywallsDir); - const componentFiles = listSourceFiles(componentsDir); + const warn = (message: string): Effect.Effect => { + if (onWarn) return onWarn(message); + return Effect.void; + }; + const voidhashDir = path.join(projectRoot, ".voidhash"); + const paywallsDir = path.join(voidhashDir, "paywalls"); + const componentsDir = path.join(voidhashDir, "components"); + const outDir = path.join(projectRoot, BUILD_DIR); + + const paywallFiles = yield* listSourceFiles(fs, path, paywallsDir); + const componentFiles = yield* listSourceFiles(fs, path, componentsDir); if (paywallFiles.length === 0 && componentFiles.length === 0) { return yield* Effect.fail( @@ -801,8 +908,8 @@ export const buildPaywalls = ({ ); } - yield* validateIds("paywall", paywallFiles); - yield* validateIds("component", componentFiles); + yield* validateIds(path, "paywall", paywallFiles); + yield* validateIds(path, "component", componentFiles); // Typecheck gate: fail fast, before any bundling. yield* typecheckPaywallSources({ @@ -815,10 +922,13 @@ export const buildPaywalls = ({ ); // Clear any previous build so removed paywalls/components don't linger. - yield* Effect.tryPromise({ - try: () => fsp.rm(outDir, { force: true, recursive: true }), - catch: (cause) => new PaywallBuildError({ cause, message: "Failed to clean build dir" }), - }); + yield* fs + .remove(outDir, { force: true, recursive: true }) + .pipe( + Effect.mapError( + (cause) => new PaywallBuildError({ cause, message: "Failed to clean build dir" }), + ), + ); // Register esbuild so we can `require` paywall/component modules (JSX) to // read metadata and render preview trees. @@ -830,27 +940,33 @@ export const buildPaywalls = ({ const paywalls: DeployPaywall[] = []; for (const file of paywallFiles) { - const id = idFromFile(file); - const meta = yield* loadPaywallMeta(file); - const built = yield* bundlePaywall(projectRoot, voidhashDir, file); + const id = idFromFile(path, file); + const meta = yield* loadPaywallMeta(path, file); + const built = yield* bundlePaywall(path, projectRoot, voidhashDir, file); - const paywallOutDir = join(outDir, "paywalls", id); + const paywallOutDir = path.join(outDir, "paywalls", id); const html = yield* writeArtifact( + fs, + path, projectRoot, - join(paywallOutDir, "index.html"), + path.join(paywallOutDir, "index.html"), built.htmlBytes, ); const js = yield* writeArtifact( + fs, + path, projectRoot, - join(paywallOutDir, built.jsFileName), + path.join(paywallOutDir, built.jsFileName), built.jsBytes, ); const referencedAssets: string[] = []; for (const asset of built.assets) { const deployAsset = yield* writeArtifact( + fs, + path, projectRoot, - join(paywallOutDir, asset.relName), + path.join(paywallOutDir, asset.relName), asset.bytes, ); assetIndex.set(deployAsset.path, deployAsset); @@ -858,13 +974,13 @@ export const buildPaywalls = ({ } referencedAssets.sort(); - const source = yield* readDeployFile(projectRoot, file); + const source = yield* readDeployFile(fs, path, projectRoot, file); paywalls.push({ artifacts: { html, js }, assets: referencedAssets, contentHash: computePaywallContentHash({ - assetSha256s: referencedAssets.map((path) => assetIndex.get(path)?.sha256 ?? ""), + assetSha256s: referencedAssets.map((assetPath) => assetIndex.get(assetPath)?.sha256 ?? ""), htmlSha256: html.sha256, jsSha256: js.sha256, }), @@ -893,9 +1009,9 @@ export const buildPaywalls = ({ const react = yield* requireFromProject(projectRoot, "react"); for (const file of componentFiles) { - const id = idFromFile(file); - const definition = yield* loadComponentDefinition(file); - const componentOutDir = join(outDir, "components", id); + const id = idFromFile(path, file); + const definition = yield* loadComponentDefinition(path, file); + const componentOutDir = path.join(outDir, "components", id); // §2 component manifest. const manifestJson = yield* Effect.try({ @@ -905,34 +1021,37 @@ export const buildPaywalls = ({ cause, message: `Failed to extract the manifest of component "${id}"` + - (cause instanceof Error ? `: ${cause.message}` : "."), + manifestFailureSuffix(cause), }), }); const manifest = yield* writeArtifact( + fs, + path, projectRoot, - join(componentOutDir, "manifest.json"), - textEncoder.encode(`${JSON.stringify(manifestJson, null, 2)}\n`), + path.join(componentOutDir, "manifest.json"), + yield* toJsonBytes(`the manifest of component "${id}"`, manifestJson), ); // §3 preview trees — one per declared state, always including // "default" (rendered with prop defaults when not declared). - const previewStates: Record = { + const previewStates: Record = { default: definition.previews.default ?? {}, ...definition.previews, }; const previews: DeployComponentPreview[] = []; for (const [state, preview] of Object.entries(previewStates)) { + const data = readProperty(preview, "data"); const tree = yield* Effect.tryPromise({ try: () => treeLib.renderToNodeTree( - react.createElement(definition.component, preview.props ?? {}), + react.createElement(definition.component, recordOf(readProperty(preview, "props"))), { config: { - products: preview.data?.products ?? [], - variables: preview.data?.variables ?? {}, - platform: preview.data?.platform, - safeAreaInsets: preview.data?.safeAreaInsets, - dimensions: preview.data?.dimensions, + products: readArray(readProperty(data, "products")), + variables: recordOf(readProperty(data, "variables")), + platform: readProperty(data, "platform"), + safeAreaInsets: readProperty(data, "safeAreaInsets"), + dimensions: readProperty(data, "dimensions"), }, state, }, @@ -954,18 +1073,22 @@ export const buildPaywalls = ({ } const previewFile = yield* writeArtifact( + fs, + path, projectRoot, - join(componentOutDir, "previews", `${state}.json`), - textEncoder.encode(`${JSON.stringify(tree, null, 2)}\n`), + path.join(componentOutDir, "previews", `${state}.json`), + yield* toJsonBytes(`preview "${state}" of component "${id}"`, tree), ); previews.push({ file: previewFile, state }); } // Runtime bundle (and panel bundle, when declared). - const runtimeBytes = yield* bundleComponentRuntime(voidhashDir, file); + const runtimeBytes = yield* bundleComponentRuntime(path, voidhashDir, file); const runtime = yield* writeArtifact( + fs, + path, projectRoot, - join(componentOutDir, "runtime.js"), + path.join(componentOutDir, "runtime.js"), runtimeBytes, ); @@ -974,11 +1097,17 @@ export const buildPaywalls = ({ // per the reserved `artifacts.panel` contract field. let panel: DeployArtifact | null = null; if (definitionHasPanel(definition)) { - const panelBytes = yield* bundleComponentPanel(voidhashDir, file); - panel = yield* writeArtifact(projectRoot, join(componentOutDir, "panel.js"), panelBytes); + const panelBytes = yield* bundleComponentPanel(path, voidhashDir, file); + panel = yield* writeArtifact( + fs, + path, + projectRoot, + path.join(componentOutDir, "panel.js"), + panelBytes, + ); } - const source = yield* readDeployFile(projectRoot, file); + const source = yield* readDeployFile(fs, path, projectRoot, file); components.push({ artifacts: { panel, runtime }, @@ -1001,9 +1130,22 @@ export const buildPaywalls = ({ // ── Manifest ───────────────────────────────────────────────────────────── - const configFile = ["ts", "js", "cjs", "mjs"] - .map((ext) => join(projectRoot, `voidhash.config.${ext}`)) - .find((p) => existsSync(p)); + let configFile: string | undefined; + for (const ext of ["ts", "js", "cjs", "mjs"]) { + const candidate = path.join(projectRoot, `voidhash.config.${ext}`); + const exists = yield* fs + .exists(candidate) + .pipe( + Effect.mapError( + (cause) => + new PaywallBuildError({ cause, message: `Failed to look for ${candidate}` }), + ), + ); + if (exists) { + configFile = candidate; + break; + } + } if (!configFile) { return yield* Effect.fail( new PaywallBuildError({ @@ -1011,14 +1153,16 @@ export const buildPaywalls = ({ }), ); } - const config = yield* readDeployFile(projectRoot, configFile); + const config = yield* readDeployFile(fs, path, projectRoot, configFile); + + const now = yield* DateTime.nowAsDate; const manifest: DeployManifest = { assets: [...assetIndex.values()].sort((a, b) => a.path.localeCompare(b.path)), cliVersion, components, config, - createdAt: new Date().toISOString(), + createdAt: now.toISOString(), paywalls, project, runtimeVersion, @@ -1038,8 +1182,13 @@ export const buildPaywalls = ({ ), ); - const manifestPath = join(outDir, "manifest.json"); - yield* writeFile(manifestPath, textEncoder.encode(`${JSON.stringify(manifest, null, 2)}\n`)); + const manifestPath = path.join(outDir, "manifest.json"); + yield* writeFile( + fs, + path, + manifestPath, + yield* toJsonBytes("the deploy manifest", manifest), + ); return { manifest, manifestPath, outDir }; }); diff --git a/apps/cli/src/domain/services/paywall-closed-imports.ts b/apps/cli/src/domain/services/paywall-closed-imports.ts index 0a68518cf..6983e6a51 100644 --- a/apps/cli/src/domain/services/paywall-closed-imports.ts +++ b/apps/cli/src/domain/services/paywall-closed-imports.ts @@ -4,9 +4,8 @@ * anything else (react-dom, lodash, app code outside `.voidhash`, …) fails the * build with an error naming the offending import. */ -import { realpathSync } from "node:fs"; -import { isAbsolute, resolve, sep } from "node:path"; - +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Path } from "effect"; import type * as esbuild from "esbuild"; /** Bare specifiers `.voidhash` sources may import. */ @@ -22,19 +21,22 @@ const PAYWALLS_PACKAGE = "@voidhash/paywalls"; const FORBIDDEN_PAYWALLS_SUBPATH = `${PAYWALLS_PACKAGE}/tree`; /** Resolves symlinks (macOS tmp dirs, pnpm) so containment checks compare real paths. */ -const toRealPath = (path: string): string => { - try { - return realpathSync(path); - } catch { - return resolve(path); - } -}; +const toRealPath = (target: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fileSystem + .realPath(target) + .pipe(Effect.orElseSucceed(() => path.resolve(target))); + }); -const isPathWithin = (parent: string, child: string): boolean => { - const parentPath = toRealPath(parent); - const childPath = toRealPath(child); - return childPath === parentPath || childPath.startsWith(parentPath + sep); -}; +const isPathWithin = (parent: string, child: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const parentPath = yield* toRealPath(parent); + const childPath = yield* toRealPath(child); + return childPath === parentPath || childPath.startsWith(parentPath + path.sep); + }); const isAllowedBareImport = (specifier: string): boolean => { if (ALLOWED_BARE_IMPORTS.includes(specifier)) { @@ -64,54 +66,70 @@ const disallowedMessage = (specifier: string, importer: string): string => * * @param voidhashDir Absolute path to the project's `.voidhash` directory. */ -export const closedImportsPlugin = (voidhashDir: string): esbuild.Plugin => ({ - name: "voidhash-closed-imports", - setup(build) { - build.onResolve({ filter: /.*/ }, (args) => { - if (args.kind === "entry-point") { - return null; - } - // Only user-authored sources are constrained. Synthetic stdin entries - // (non-absolute importer) count as user sources. - const fromUserSource = !isAbsolute(args.importer) || isPathWithin(voidhashDir, args.importer); - if (!fromUserSource) { - return null; - } +const resolveImport = ( + voidhashDir: string, + args: esbuild.OnResolveArgs, +): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path; - const specifier = args.path; + if (args.kind === "entry-point") { + return null; + } + // Only user-authored sources are constrained. Synthetic stdin entries + // (non-absolute importer) count as user sources. + const fromUserSource = + !path.isAbsolute(args.importer) || (yield* isPathWithin(voidhashDir, args.importer)); + if (!fromUserSource) { + return null; + } - if (specifier.startsWith(".")) { - const target = resolve(args.resolveDir, specifier); - if (!isPathWithin(voidhashDir, target)) { - return { - errors: [ - { - text: - `Import "${specifier}" (in ${args.importer}) escapes the ` + - ".voidhash directory. Paywall sources may only import files " + - "within .voidhash.", - }, - ], - }; - } - return null; - } + const specifier = args.path; - if (isAbsolute(specifier)) { - return isPathWithin(voidhashDir, specifier) - ? null - : { - errors: [{ text: disallowedMessage(specifier, args.importer) }], - }; + if (specifier.startsWith(".")) { + const target = path.resolve(args.resolveDir, specifier); + if (!(yield* isPathWithin(voidhashDir, target))) { + return { + errors: [ + { + text: + `Import "${specifier}" (in ${args.importer}) escapes the ` + + ".voidhash directory. Paywall sources may only import files " + + "within .voidhash.", + }, + ], + }; } + return null; + } - if (isAllowedBareImport(specifier)) { + if (path.isAbsolute(specifier)) { + if (yield* isPathWithin(voidhashDir, specifier)) { return null; } - return { errors: [{ text: disallowedMessage(specifier, args.importer) }], }; - }); + } + + if (isAllowedBareImport(specifier)) { + return null; + } + + return { + errors: [{ text: disallowedMessage(specifier, args.importer) }], + }; + }); + +export const closedImportsPlugin = (voidhashDir: string): esbuild.Plugin => ({ + name: "voidhash-closed-imports", + setup(build) { + // esbuild allows an async `onResolve`; the path checks read the real + // filesystem through the platform `FileSystem`, which has no sync surface. + build.onResolve({ filter: /.*/ }, (args) => + Effect.runPromise( + resolveImport(voidhashDir, args).pipe(Effect.provide(NodeServices.layer)), + ), + ); }, }); diff --git a/apps/cli/src/domain/services/paywall-deploy-upload.ts b/apps/cli/src/domain/services/paywall-deploy-upload.ts index fb904b99f..ff44f8e13 100644 --- a/apps/cli/src/domain/services/paywall-deploy-upload.ts +++ b/apps/cli/src/domain/services/paywall-deploy-upload.ts @@ -4,10 +4,17 @@ * follows the CLI's API conventions — `api_url` base + `x-api-key` header from * the user's CLI config. */ -import { promises as fsp } from "node:fs"; -import { join } from "node:path"; - -import { Data, Effect, Schema } from "effect"; +import { + Data, + Effect, + FileSystem, + Match, + Option, + Path, + Schema, + SchemaGetter, + SchemaTransformation, +} from "effect"; import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"; import type { DeployManifest } from "../schema/paywall-deploy"; @@ -85,13 +92,26 @@ export const collectManifestFiles = (manifest: DeployManifest): Map { - try { - return JSON.parse(body); - } catch { - return; - } -}; +/** JSON text codec used to read server response bodies. */ +const JsonText = Schema.UnknownFromJsonString; + +/** + * JSON text codec used to re-render a server response body for the user. + * `space: 2` keeps the printed detail block readable, as it was before. + */ +const PrettyJsonText = Schema.String.pipe( + Schema.decodeTo( + Schema.Unknown, + new SchemaTransformation.Transformation( + SchemaGetter.parseJson(), + SchemaGetter.stringifyJson({ space: 2 }), + ), + ), +); + +/** Parses a response body as JSON, `None` when it is not JSON at all. */ +const tryParseJson = (body: string): Option.Option => + Schema.decodeUnknownOption(JsonText)(body); /** * Extracts the `missing` hash list from a finalize `409` body (contract §4.3: @@ -99,7 +119,7 @@ const tryParseJson = (body: string): unknown => { * such list — callers then fall back to the generic failure path. */ const readMissingHashes = (body: string): string[] | undefined => { - const parsed = tryParseJson(body); + const parsed = Option.getOrUndefined(tryParseJson(body)); if (typeof parsed !== "object" || parsed === null || !("missing" in parsed)) { return; } @@ -114,23 +134,39 @@ const readMissingHashes = (body: string): string[] | undefined => { return missing; }; +/** The actionable hint appended to a failure of the given HTTP status. */ +const failureHint = (status: number): string => + Match.value(status).pipe( + Match.when( + 400, + () => " The server rejected the manifest — your CLI may be outdated; try upgrading voidhash-cli.", + ), + Match.when(401, () => " Authentication failed. Run 'voidhash-cli auth login' and retry."), + Match.when( + 403, + () => " Check that the team/project in voidhash.config.ts match a project you have access to.", + ), + Match.when( + 409, + () => " The deploy is incomplete (blobs missing server-side). Re-run deploy to retry.", + ), + Match.when(422, () => " The server rejected the deploy contents:"), + Match.orElse(() => ""), + ); + +/** The response body block appended below the failure line, if any. */ +const detailsBlock = (details: string): string => { + if (details) return `\n${details}`; + return ""; +}; + /** Renders a non-2xx response into an actionable message (esp. 422 details). */ const describeHttpFailure = (step: string, status: number, body: string): string => { - const parsed = tryParseJson(body); - const details = parsed !== undefined ? JSON.stringify(parsed, null, 2) : body.trim(); - const hint = - status === 400 - ? " The server rejected the manifest — your CLI may be outdated; try upgrading voidhash-cli." - : status === 401 - ? " Authentication failed. Run 'voidhash-cli auth login' and retry." - : status === 403 - ? " Check that the team/project in voidhash.config.ts match a project you have access to." - : status === 409 - ? " The deploy is incomplete (blobs missing server-side). Re-run deploy to retry." - : status === 422 - ? " The server rejected the deploy contents:" - : ""; - return `${step} failed with status ${status}.${hint}${details ? `\n${details}` : ""}`; + const details = tryParseJson(body).pipe( + Option.flatMap(Schema.encodeUnknownOption(PrettyJsonText)), + Option.getOrElse(() => body.trim()), + ); + return `${step} failed with status ${status}.${failureHint(status)}${detailsBlock(details)}`; }; const failHttp = ( @@ -207,11 +243,13 @@ export const uploadPaywallDeploy = ({ }: UploadPaywallDeployOptions): Effect.Effect< UploadPaywallDeployResult, PaywallDeployUploadError, - HttpClient.HttpClient | CliConfig + HttpClient.HttpClient | CliConfig | FileSystem.FileSystem | Path.Path > => Effect.gen(function* uploadPaywallDeploy() { const httpClient = yield* HttpClient.HttpClient; const cliConfig = yield* CliConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const config = yield* cliConfig.readConfig().pipe( Effect.mapError( @@ -244,8 +282,10 @@ export const uploadPaywallDeploy = ({ ) .pipe(Effect.mapError(networkFailure(step))); - const report = (message: string): Effect.Effect => - onProgress ? onProgress(message) : Effect.void; + const report = (message: string): Effect.Effect => { + if (onProgress) return onProgress(message); + return Effect.void; + }; // 1. Create the deploy from the manifest. const createStep = "Creating the deploy"; @@ -275,14 +315,15 @@ export const uploadPaywallDeploy = ({ }), ); } - const bytes = yield* Effect.tryPromise({ - try: () => fsp.readFile(join(projectRoot, relPath)), - catch: (cause) => - new PaywallDeployUploadError({ - cause, - message: `Failed to read ${relPath} for upload.`, - }), - }); + const bytes = yield* fs.readFile(path.join(projectRoot, relPath)).pipe( + Effect.mapError( + (cause) => + new PaywallDeployUploadError({ + cause, + message: `Failed to read ${relPath} for upload.`, + }), + ), + ); const uploadStep = `Uploading ${relPath}`; const uploadResponse = yield* send( uploadStep, diff --git a/apps/cli/src/domain/services/paywall-typecheck.ts b/apps/cli/src/domain/services/paywall-typecheck.ts index e11c76617..5995159a0 100644 --- a/apps/cli/src/domain/services/paywall-typecheck.ts +++ b/apps/cli/src/domain/services/paywall-typecheck.ts @@ -3,9 +3,8 @@ * `.voidhash` sources are typechecked with the TypeScript compiler API using * the project's own `tsconfig.json`, and the build fails listing diagnostics. */ -import { dirname, join } from "node:path"; - -import { Data, Effect } from "effect"; +import { constant } from "@voidhash/lib/lang"; +import { Data, Effect, Path } from "effect"; import ts from "typescript"; export class PaywallTypecheckError extends Data.TaggedError("PaywallTypecheckError")<{ @@ -19,7 +18,7 @@ export class PaywallTypecheckError extends Data.TaggedError("PaywallTypecheckErr * `import hero from "./hero.png"` typechecks, and the bundler emits/inlines * the file. */ -export const PAYWALL_ASSET_EXTENSIONS = [ +export const PAYWALL_ASSET_EXTENSIONS = constant([ "png", "jpg", "jpeg", @@ -30,7 +29,7 @@ export const PAYWALL_ASSET_EXTENSIONS = [ "otf", "woff", "woff2", -] as const; +]); /** Ambient `declare module "*.png" { … }` block per supported asset extension. */ const ASSET_MODULE_DECLARATIONS = PAYWALL_ASSET_EXTENSIONS.map( @@ -60,32 +59,59 @@ const formatHost: ts.FormatDiagnosticsHost = { getNewLine: () => ts.sys.newLine, }; +/** Message for an unknown failure raised by the TypeScript compiler API. */ +const typecheckFailureMessage = (cause: unknown): string => { + if (cause instanceof Error) return cause.message; + return "Failed to typecheck .voidhash sources."; +}; + +/** Runs a synchronous TypeScript compiler API call as a typed failure. */ +const attempt = (thunk: () => A): Effect.Effect => + Effect.try({ + try: thunk, + catch: (cause) => + new PaywallTypecheckError({ cause, message: typecheckFailureMessage(cause) }), + }); + const loadCompilerOptions = ( + path: Path.Path, projectRoot: string, -): { options: ts.CompilerOptions; configPath: string | undefined } => { - const configPath = ts.findConfigFile(projectRoot, ts.sys.fileExists, "tsconfig.json"); - if (!configPath) { - return { configPath: undefined, options: { ...FALLBACK_OPTIONS } }; - } +): Effect.Effect< + { options: ts.CompilerOptions; configPath: string | undefined }, + PaywallTypecheckError +> => + Effect.gen(function* loadCompilerOptions() { + const configPath = yield* attempt(() => + ts.findConfigFile(projectRoot, ts.sys.fileExists, "tsconfig.json"), + ); + if (!configPath) { + return { configPath: undefined, options: { ...FALLBACK_OPTIONS } }; + } - const read = ts.readConfigFile(configPath, ts.sys.readFile); - if (read.error) { - throw new Error(ts.formatDiagnostics([read.error], formatHost)); - } - const parsed = ts.parseJsonConfigFileContent( - read.config, - ts.sys, - dirname(configPath), - undefined, - configPath, - ); - // "no inputs were found" (18003) is irrelevant — we supply our own roots. - const configErrors = parsed.errors.filter((e) => e.code !== 18_003); - if (configErrors.length > 0) { - throw new Error(ts.formatDiagnostics(configErrors, formatHost)); - } - return { configPath, options: parsed.options }; -}; + const read = yield* attempt(() => ts.readConfigFile(configPath, ts.sys.readFile)); + if (read.error) { + return yield* new PaywallTypecheckError({ + message: ts.formatDiagnostics([read.error], formatHost), + }); + } + const parsed = yield* attempt(() => + ts.parseJsonConfigFileContent( + read.config, + ts.sys, + path.dirname(configPath), + undefined, + configPath, + ), + ); + // "no inputs were found" (18003) is irrelevant — we supply our own roots. + const configErrors = parsed.errors.filter((e) => e.code !== 18_003); + if (configErrors.length > 0) { + return yield* new PaywallTypecheckError({ + message: ts.formatDiagnostics(configErrors, formatHost), + }); + } + return { configPath, options: parsed.options }; + }); /** * Wraps a compiler host so the in-memory asset declaration file exists at @@ -98,12 +124,21 @@ const withAssetDeclarations = (host: ts.CompilerHost, assetDeclPath: string): ts return { ...host, fileExists: (fileName) => fileName === assetDeclPath || fileExists(fileName), - getSourceFile: (fileName, languageVersionOrOptions, ...rest) => - fileName === assetDeclPath - ? ts.createSourceFile(fileName, ASSET_MODULE_DECLARATIONS, languageVersionOrOptions, true) - : getSourceFile(fileName, languageVersionOrOptions, ...rest), - readFile: (fileName) => - fileName === assetDeclPath ? ASSET_MODULE_DECLARATIONS : readFile(fileName), + getSourceFile: (fileName, languageVersionOrOptions, ...rest) => { + if (fileName === assetDeclPath) { + return ts.createSourceFile( + fileName, + ASSET_MODULE_DECLARATIONS, + languageVersionOrOptions, + true, + ); + } + return getSourceFile(fileName, languageVersionOrOptions, ...rest); + }, + readFile: (fileName) => { + if (fileName === assetDeclPath) return ASSET_MODULE_DECLARATIONS; + return readFile(fileName); + }, }; }; @@ -118,42 +153,38 @@ const withAssetDeclarations = (host: ts.CompilerHost, assetDeclPath: string): ts export const typecheckPaywallSources = (options: { readonly projectRoot: string; readonly files: ReadonlyArray; -}): Effect.Effect => - Effect.try({ - try: () => { - const { options: compilerOptions } = loadCompilerOptions(options.projectRoot); +}): Effect.Effect => + Effect.gen(function* typecheckPaywallSources() { + const path = yield* Path.Path; + const { options: compilerOptions } = yield* loadCompilerOptions(path, options.projectRoot); - const finalOptions: ts.CompilerOptions = { - ...compilerOptions, - // The gate only checks — never emit, and never stumble over - // third-party declaration files. - incremental: false, - jsx: compilerOptions.jsx ?? ts.JsxEmit.ReactJSX, - noEmit: true, - skipLibCheck: true, - }; + const finalOptions: ts.CompilerOptions = { + ...compilerOptions, + // The gate only checks — never emit, and never stumble over + // third-party declaration files. + incremental: false, + jsx: compilerOptions.jsx ?? ts.JsxEmit.ReactJSX, + noEmit: true, + skipLibCheck: true, + }; - const assetDeclPath = join(options.projectRoot, ASSET_DECLARATIONS_FILE_NAME); + const assetDeclPath = path.join(options.projectRoot, ASSET_DECLARATIONS_FILE_NAME); + const diagnostics = yield* attempt(() => { const program = ts.createProgram({ host: withAssetDeclarations(ts.createCompilerHost(finalOptions), assetDeclPath), options: finalOptions, rootNames: [...options.files, assetDeclPath], }); - - const diagnostics = ts + return ts .getPreEmitDiagnostics(program) .filter((d) => d.category === ts.DiagnosticCategory.Error); + }); - if (diagnostics.length > 0) { - throw new Error( + if (diagnostics.length > 0) { + return yield* new PaywallTypecheckError({ + message: `TypeScript found ${diagnostics.length} error(s) in .voidhash sources:\n\n` + - ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost), - ); - } - }, - catch: (cause) => - new PaywallTypecheckError({ - cause, - message: cause instanceof Error ? cause.message : "Failed to typecheck .voidhash sources.", - }), + ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost), + }); + } }); diff --git a/apps/cli/src/domain/services/schema.ts b/apps/cli/src/domain/services/schema.ts index 1fd76b2d6..6a2766714 100644 --- a/apps/cli/src/domain/services/schema.ts +++ b/apps/cli/src/domain/services/schema.ts @@ -1,3 +1,4 @@ +import { constant } from "@voidhash/lib/lang"; import { Effect, Layer, Context } from "effect"; import { ApiClient } from "../../utils/api-client"; @@ -8,6 +9,15 @@ import { type NormalizedSchema, } from "../schema/normalized-schema"; +const SUPPORTED_PROVIDER_IDS: ReadonlySet = new Set([ + "appleAppStore", + "googlePlay", +] satisfies ReadonlyArray); + +/** Narrows a provider id reported by the API to one the CLI understands. */ +const isSupportedProviderId = (providerId: string): providerId is ProviderId => + SUPPORTED_PROVIDER_IDS.has(providerId); + const make = Effect.gen(function* effect() { const apiClient = yield* ApiClient; @@ -42,21 +52,20 @@ const make = Effect.gen(function* effect() { }); } - const SUPPORTED_PROVIDER_IDS: ReadonlySet = new Set([ - "appleAppStore", - "googlePlay", - ]); for (const product of response.products) { schema.products.set(product.slug, { name: product.name, perks: [...product.perks], - providers: product.providers - .filter((provider) => SUPPORTED_PROVIDER_IDS.has(provider.providerId as string)) - .map((provider) => ({ - configuration: provider.configuration, - providerId: provider.providerId as ProviderId, - })), + providers: product.providers.flatMap((provider) => { + if (!isSupportedProviderId(provider.providerId)) return []; + return [ + { + configuration: provider.configuration, + providerId: provider.providerId, + }, + ]; + }), slug: product.slug, type: product.type, }); @@ -106,10 +115,10 @@ const make = Effect.gen(function* effect() { ), ); - return { + return constant({ fetchRemoteSchema, fetchSchemaVersion, - } as const; + }); }); type SchemaServiceShape = Effect.Success; diff --git a/apps/cli/src/domain/services/source-code.ts b/apps/cli/src/domain/services/source-code.ts index c5bd0ef25..89d13dea6 100644 --- a/apps/cli/src/domain/services/source-code.ts +++ b/apps/cli/src/domain/services/source-code.ts @@ -1,3 +1,4 @@ +import { constant } from "@voidhash/lib/lang"; import { Effect, FileSystem, Layer, Path, Schema, Context } from "effect"; import { safeRegister } from "../../utils/js-loading/js-file-loading"; @@ -15,6 +16,9 @@ import { import { PackageJsonSchema } from "../schema/package-json"; import { VoidhashConfigSchema } from "../schema/voidhash-config"; +/** Decodes the raw `package.json` text into the validated manifest. */ +const PackageJsonJson = Schema.fromJsonString(PackageJsonSchema); + const make = Effect.gen(function* effect() { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -109,7 +113,7 @@ const make = Effect.gen(function* effect() { } const packageJson = yield* fs.readFileString(packageJsonPath); - return yield* Schema.decodeUnknownEffect(PackageJsonSchema)(JSON.parse(packageJson)).pipe( + return yield* Schema.decodeUnknownEffect(PackageJsonJson)(packageJson).pipe( Effect.catchTag("SchemaError", (e) => Effect.fail( new InvalidPackageJsonError({ @@ -280,7 +284,7 @@ const make = Effect.gen(function* effect() { yield* fs.remove(voidhashConfigPath); }); - return { + return constant({ deleteVoidhashConfig, detectMonorepoRootPath, detectPackageManager, @@ -288,7 +292,7 @@ const make = Effect.gen(function* effect() { loadPackageJson, loadVoidhashConfig, retrieveSrcDir, - } as const; + }); }); type SourceCodeShape = Effect.Success; diff --git a/apps/cli/src/services/auth/index.ts b/apps/cli/src/services/auth/index.ts index a22186a7c..6f9d011d5 100644 --- a/apps/cli/src/services/auth/index.ts +++ b/apps/cli/src/services/auth/index.ts @@ -1,8 +1,6 @@ import { Effect, Layer, Context } from "effect"; -const make = Effect.gen(function* scoped() { - return {} as const; -}); +const make = Effect.sync(() => ({})); type AuthServiceShape = Effect.Success; diff --git a/apps/cli/src/services/auth/utils/better-auth.ts b/apps/cli/src/services/auth/utils/better-auth.ts index 4df81b91a..25f869a1d 100644 --- a/apps/cli/src/services/auth/utils/better-auth.ts +++ b/apps/cli/src/services/auth/utils/better-auth.ts @@ -24,19 +24,22 @@ const make = Effect.gen(function* effect() { client: typeof authClient, ) => Promise<{ error: E; data?: null } | { error?: null; data: D }>, ) => - Effect.tryPromise({ - catch: (error) => - new BetterAuthClientError({ - cause: error, + Effect.gen(function* use() { + const res = yield* Effect.tryPromise({ + catch: (error) => + new BetterAuthClientError({ + cause: error, + message: "Failed to use better-auth client", + }), + try: () => fn(authClient), + }); + if (res.error) { + return yield* new BetterAuthClientError({ + cause: res.error, message: "Failed to use better-auth client", - }), - try: async () => { - const res = await fn(authClient); - if (res.error) { - throw res.error; - } - return res.data; - }, + }); + } + return res.data; }), }; }); diff --git a/apps/cli/src/services/cli-config/index.ts b/apps/cli/src/services/cli-config/index.ts index 0dfddff68..178190ae1 100644 --- a/apps/cli/src/services/cli-config/index.ts +++ b/apps/cli/src/services/cli-config/index.ts @@ -1,8 +1,6 @@ import { Effect, Layer, Context } from "effect"; -const make = Effect.gen(function* scoped() { - return {} as const; -}); +const make = Effect.sync(() => ({})); type CliConfigServiceShape = Effect.Success; diff --git a/apps/cli/src/services/organization/index.ts b/apps/cli/src/services/organization/index.ts index 99511c2e5..96345c455 100644 --- a/apps/cli/src/services/organization/index.ts +++ b/apps/cli/src/services/organization/index.ts @@ -1,8 +1,6 @@ import { Effect, Layer, Context } from "effect"; -const make = Effect.gen(function* scoped() { - return {} as const; -}); +const make = Effect.sync(() => ({})); type OrganizationServiceShape = Effect.Success; diff --git a/apps/cli/src/services/project/index.ts b/apps/cli/src/services/project/index.ts index 94be9ccdc..46ad8b0a1 100644 --- a/apps/cli/src/services/project/index.ts +++ b/apps/cli/src/services/project/index.ts @@ -1,8 +1,6 @@ import { Effect, Layer, Context } from "effect"; -const make = Effect.gen(function* scoped() { - return {} as const; -}); +const make = Effect.sync(() => ({})); type ProjectServiceShape = Effect.Success; diff --git a/apps/cli/src/services/repository/index.ts b/apps/cli/src/services/repository/index.ts index da583e606..dfdd864c1 100644 --- a/apps/cli/src/services/repository/index.ts +++ b/apps/cli/src/services/repository/index.ts @@ -1,8 +1,6 @@ import { Effect, Layer, Context } from "effect"; -const make = Effect.gen(function* scoped() { - return {} as const; -}); +const make = Effect.sync(() => ({})); type RepositoryServiceShape = Effect.Success; diff --git a/apps/cli/src/utils/api-client.ts b/apps/cli/src/utils/api-client.ts index 8c30ae8f6..5b54b72f6 100644 --- a/apps/cli/src/utils/api-client.ts +++ b/apps/cli/src/utils/api-client.ts @@ -1,14 +1,20 @@ -import { make as makeCoreClient, type VoidhashCoreClient } from "@voidhash/generated-clients"; +import { make as makeCoreClient } from "@voidhash/generated-clients"; import { Effect, Layer, Context } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { CliConfig } from "../domain/services/cli-config"; +/** Builds the API key header set, empty when no key is configured. */ +const apiKeyHeaders = (apiKey: string | null | undefined): Record => { + if (apiKey) return { "x-api-key": apiKey }; + return {}; +}; + const make = Effect.gen(function* effect() { yield* Effect.logDebug("Initializing API client"); const cliConfig = yield* CliConfig; const httpClient = yield* HttpClient.HttpClient; - return makeCoreClient(httpClient as VoidhashCoreClient["httpClient"], { + return makeCoreClient(httpClient, { transformClient: (client) => Effect.succeed( client.pipe( @@ -22,7 +28,7 @@ const make = Effect.gen(function* effect() { return HttpClientRequest.setHeaders( HttpClientRequest.prependUrl(request, config.api_url), - config.api_key ? { "x-api-key": config.api_key } : {}, + apiKeyHeaders(config.api_key), ); }).pipe(Effect.withSpan("ApiClient.transformRequest")), ), diff --git a/apps/cli/src/utils/error-formatter.ts b/apps/cli/src/utils/error-formatter.ts index 488ade62b..fd888e607 100644 --- a/apps/cli/src/utils/error-formatter.ts +++ b/apps/cli/src/utils/error-formatter.ts @@ -1,5 +1,5 @@ import { CliError } from "effect/unstable/cli"; -import { Cause, Console, Effect } from "effect"; +import { Cause, Console, Data, Effect } from "effect"; const CliErrorTypeId = Symbol.for("~effect/cli/CliError"); @@ -42,8 +42,16 @@ export const getActiveProfile = (): string | null => { * ) * ``` */ +/** + * The cause carried by a {@link userError} — an ordinary `Error` (so + * `Cause.pretty` renders it) whose only payload is the user-facing message. + */ +export class UserMessageError extends Data.TaggedError("UserMessageError")<{ + readonly message: string; +}> {} + export const userError = (message: string): CliError.UserError => - new CliError.UserError({ cause: new Error(message) }); + new CliError.UserError({ cause: new UserMessageError({ message }) }); /** * Check if an error is a CliError from @effect/cli @@ -97,13 +105,19 @@ export const withValidationErrorHandler = ( ); } - // Re-fail with non-CliError - const firstFailure = failures[0]; + // Re-fail with non-CliError. Every CliError already returned above, so + // whatever is left in `failures` is outside the excluded union. + const firstFailure = failures.find( + (failure): failure is Exclude => !isCliError(failure), + ); if (firstFailure !== undefined) { - return Effect.fail(firstFailure as Exclude); + return Effect.fail(firstFailure); } // Handle defects - return Effect.failCause(cause as Cause.Cause>); + const defects = cause.reasons.filter( + (reason): reason is Cause.Die | Cause.Interrupt => reason._tag !== "Fail", + ); + return Effect.failCause(Cause.fromReasons(defects)); }), ); diff --git a/apps/cli/src/utils/js-loading/js-file-loading.ts b/apps/cli/src/utils/js-loading/js-file-loading.ts index 38378dbaa..ab6a18d99 100644 --- a/apps/cli/src/utils/js-loading/js-file-loading.ts +++ b/apps/cli/src/utils/js-loading/js-file-loading.ts @@ -7,13 +7,22 @@ export class FailedToLoadJsFileError extends Data.TaggedError("FailedToLoadJsFil readonly cause?: unknown; }> {} +/** + * Lazily loads the tiny TypeScript probe used to detect an esbuild-register + * setup that cannot compile to es5. + */ +const loadEs5Probe = () => import("./_es5"); + +/** Lazily loads esbuild-register, which patches require() to compile TS. */ +const loadEsbuildRegister = () => import("esbuild-register/dist/node"); + const assertES5 = ({ unregister }: { unregister: () => void }) => - Effect.try({ - try: () => require("./_es5.ts"), + Effect.tryPromise({ + try: loadEs5Probe, catch: (e: any) => { unregister(); if ("errors" in e && Array.isArray(e.errors) && e.errors.length > 0) { - const es5Error = (e.errors as any[]).some((it) => + const es5Error = e.errors.some((it: any) => it.text?.includes(`("es5") is not supported yet`), ); if (es5Error) { @@ -39,7 +48,7 @@ export const safeRegister = () => cause: e, message: "An error occurred while trying to load .js/ts file.", }), - try: () => import("esbuild-register/dist/node"), + try: loadEsbuildRegister, }); const res: { unregister: () => void } = yield* Effect.try({ catch: (e) => diff --git a/apps/cli/src/utils/organizations/create-organization.ts b/apps/cli/src/utils/organizations/create-organization.ts index c9969bf56..a9beba003 100644 --- a/apps/cli/src/utils/organizations/create-organization.ts +++ b/apps/cli/src/utils/organizations/create-organization.ts @@ -1,8 +1,6 @@ import { Prompt } from "effect/unstable/cli"; import { Console, Effect } from "effect"; -import { NoSignedInUserError } from "../../domain/errors/auth"; -import { CliConfig } from "../../domain/services/cli-config"; import { ApiClient } from "../api-client"; const validateOrganizationName = (value: string) => { diff --git a/apps/cli/src/utils/source-code.ts b/apps/cli/src/utils/source-code.ts index 5e37e7c4d..8c717ddfe 100644 --- a/apps/cli/src/utils/source-code.ts +++ b/apps/cli/src/utils/source-code.ts @@ -6,8 +6,10 @@ import type { PackageJsonSchema } from "../domain/schema/package-json"; * @param depth - The number of directory levels to go up. * @returns The relative path prefix (e.g., './' for 0, '../' for 1, etc.). */ -export const relativePathPrefixFromDepth = (depth: number) => - depth === 0 ? "./" : `${"../".repeat(depth)}`; +export const relativePathPrefixFromDepth = (depth: number) => { + if (depth === 0) return "./"; + return "../".repeat(depth); +}; /** * Checks if the project is an Expo project. diff --git a/apps/cli/test-monorepo-detection.ts b/apps/cli/test-monorepo-detection.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/cli/tests/domain/schema/paywall-deploy.test.ts b/apps/cli/tests/domain/schema/paywall-deploy.test.ts index 1e40085cd..46255f620 100644 --- a/apps/cli/tests/domain/schema/paywall-deploy.test.ts +++ b/apps/cli/tests/domain/schema/paywall-deploy.test.ts @@ -106,14 +106,26 @@ describe("DeployManifestSchema", () => { it("accepts a component-only manifest and a panel artifact", () => { const fixture = validManifest(); - fixture.paywalls = []; - fixture.components[0]!.artifacts.panel = artifact( - ".voidhash/.build/components/product-option/panel.js", - "f", - "text/javascript; charset=utf-8", - ) as never; + const component = fixture.components[0]!; + const withPanel = { + ...fixture, + components: [ + { + ...component, + artifacts: { + ...component.artifacts, + panel: artifact( + ".voidhash/.build/components/product-option/panel.js", + "f", + "text/javascript; charset=utf-8", + ), + }, + }, + ], + paywalls: [], + }; - const manifest = decode(fixture); + const manifest = decode(withPanel); expect(manifest.components[0]?.artifacts.panel?.sha256).toBe(hash("f")); }); @@ -129,10 +141,11 @@ describe("DeployManifestSchema", () => { it("rejects non-scalar variable values", () => { const fixture = validManifest(); - fixture.paywalls[0]!.variables = { - accentColor: { hex: "#16a34a" }, - } as never; - expect(() => decode(fixture)).toThrow(); + const withObjectVariable = { + ...fixture, + paywalls: [{ ...fixture.paywalls[0]!, variables: { accentColor: { hex: "#16a34a" } } }], + }; + expect(() => decode(withObjectVariable)).toThrow(); }); it("rejects malformed sha256 digests", () => { diff --git a/apps/cli/tests/domain/services/paywall-closed-imports.test.ts b/apps/cli/tests/domain/services/paywall-closed-imports.test.ts index 0af19d135..ba0fc79d4 100644 --- a/apps/cli/tests/domain/services/paywall-closed-imports.test.ts +++ b/apps/cli/tests/domain/services/paywall-closed-imports.test.ts @@ -1,15 +1,10 @@ -import { promises as fsp } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Path } from "effect"; import * as esbuild from "esbuild"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { closedImportsPlugin } from "../../../src/domain/services/paywall-closed-imports"; -let projectRoot: string; -let voidhashDir: string; - /** Bare modules marked external so "allowed" imports need no node_modules. */ const EXTERNALS = [ "react", @@ -19,116 +14,177 @@ const EXTERNALS = [ "@voidhash/paywalls/*", ]; -const writeSource = async (relPath: string, contents: string) => { - const abs = join(projectRoot, relPath); - await fsp.mkdir(join(abs, ".."), { recursive: true }); - await fsp.writeFile(abs, contents); - return abs; +interface Fixture { + readonly projectRoot: string; + readonly voidhashDir: string; +} + +const writeSource = ( + projectRoot: string, + relPath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* writeSource() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const abs = path.join(projectRoot, relPath); + yield* fs.makeDirectory(path.dirname(abs), { recursive: true }); + yield* fs.writeFileString(abs, contents); + return abs; + }).pipe(Effect.orDie); + +/** The esbuild error texts of a failed build ([] when it was not a build failure). */ +const esbuildErrorTexts = (cause: unknown): Array => { + if (typeof cause !== "object" || cause === null || !("errors" in cause)) return []; + const errors = cause.errors; + if (!Array.isArray(errors)) return []; + return errors.map((error: esbuild.Message) => error.text); }; /** Bundles `entry` with the plugin; returns esbuild error texts ([] = ok). */ -const buildErrors = async ( +const buildErrors = ( + voidhashDir: string, entry: string, options: esbuild.BuildOptions = {}, -): Promise => { - try { - await esbuild.build({ - bundle: true, - external: EXTERNALS, - format: "esm", - logLevel: "silent", - plugins: [closedImportsPlugin(voidhashDir)], - write: false, - ...options, - entryPoints: [entry], - }); - return []; - } catch (error) { - return ((error as esbuild.BuildFailure).errors ?? []).map((e) => e.text); - } -}; - -beforeAll(async () => { - projectRoot = await fsp.mkdtemp(join(tmpdir(), "voidhash-closed-imports-")); - voidhashDir = join(projectRoot, ".voidhash"); - await writeSource(".voidhash/components/helper.ts", "export const helper = 1;\n"); - await fsp.writeFile(join(projectRoot, "app-code.ts"), "export const y = 1;\n"); -}); - -afterAll(async () => { - await fsp.rm(projectRoot, { force: true, recursive: true }); -}); - -describe("closedImportsPlugin", () => { - it("allows the allowlist plus relative imports within .voidhash", async () => { - const entry = await writeSource( - ".voidhash/components/allowed.ts", - [ - 'import "react";', - 'import "react/jsx-runtime";', - 'import "react/jsx-dev-runtime";', - 'import "@voidhash/paywalls";', - 'import "@voidhash/paywalls/dom";', - 'import "@voidhash/paywalls/panel";', - 'import { helper } from "./helper";', - "export const ok = helper;", - ].join("\n"), - ); - - expect(await buildErrors(entry)).toEqual([]); - }); - - it("rejects react-dom, naming the importing file", async () => { - const entry = await writeSource( - ".voidhash/components/uses-react-dom.ts", - 'import "react-dom";\nexport {};\n', +): Effect.Effect> => + Effect.tryPromise({ + try: () => + esbuild.build({ + bundle: true, + external: EXTERNALS, + format: "esm", + logLevel: "silent", + plugins: [closedImportsPlugin(voidhashDir)], + write: false, + ...options, + entryPoints: [entry], + }), + catch: esbuildErrorTexts, + }).pipe(Effect.match({ onFailure: (texts) => texts, onSuccess: () => [] })); + +/** + * Runs `use` against a fresh temporary project holding the shared sources the + * suite bundles against, removed again once the test finishes — the fixture + * lifecycle `beforeAll`/`afterAll` used to own. + */ +const withFixture = ( + use: (fixture: Fixture) => Effect.Effect, +): Promise => + Effect.gen(function* withFixture() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectRoot = yield* fs + .makeTempDirectory({ prefix: "voidhash-closed-imports-" }) + .pipe(Effect.orDie); + const voidhashDir = path.join(projectRoot, ".voidhash"); + + return yield* Effect.gen(function* runFixture() { + yield* writeSource(projectRoot, ".voidhash/components/helper.ts", "export const helper = 1;\n"); + yield* fs + .writeFileString(path.join(projectRoot, "app-code.ts"), "export const y = 1;\n") + .pipe(Effect.orDie); + return yield* use({ projectRoot, voidhashDir }); + }).pipe( + Effect.ensuring(fs.remove(projectRoot, { force: true, recursive: true }).pipe(Effect.orDie)), ); + }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); - const errors = await buildErrors(entry, { - external: [...EXTERNALS, "react-dom"], - }); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain('"react-dom"'); - expect(errors[0]).toContain("uses-react-dom.ts"); - }); - - it("rejects arbitrary packages", async () => { - const entry = await writeSource( - ".voidhash/components/uses-lodash.ts", - 'import "lodash";\nexport {};\n', - ); - - const errors = await buildErrors(entry); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain('"lodash"'); - }); - - it("rejects the Node-only @voidhash/paywalls/tree entry", async () => { - const entry = await writeSource( - ".voidhash/components/uses-tree.ts", - 'import "@voidhash/paywalls/tree";\nexport {};\n', - ); - - const errors = await buildErrors(entry); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain('"@voidhash/paywalls/tree"'); - }); - - it("rejects relative imports escaping .voidhash", async () => { - const entry = await writeSource( - ".voidhash/components/escapes.ts", - 'import "../../app-code";\nexport {};\n', - ); - - const errors = await buildErrors(entry); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("escapes the .voidhash directory"); - }); - - it("does not constrain imports made outside .voidhash (node_modules)", async () => { - const entry = join(projectRoot, "vendor-entry.ts"); - await fsp.writeFile(entry, 'import "react-dom";\nexport {};\n'); - - expect(await buildErrors(entry, { external: [...EXTERNALS, "react-dom"] })).toEqual([]); - }); +describe("closedImportsPlugin", () => { + it("allows the allowlist plus relative imports within .voidhash", () => + withFixture(({ projectRoot, voidhashDir }) => + Effect.gen(function* allowsAllowlist() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/components/allowed.ts", + [ + 'import "react";', + 'import "react/jsx-runtime";', + 'import "react/jsx-dev-runtime";', + 'import "@voidhash/paywalls";', + 'import "@voidhash/paywalls/dom";', + 'import "@voidhash/paywalls/panel";', + 'import { helper } from "./helper";', + "export const ok = helper;", + ].join("\n"), + ); + + expect(yield* buildErrors(voidhashDir, entry)).toEqual([]); + }), + )); + + it("rejects react-dom, naming the importing file", () => + withFixture(({ projectRoot, voidhashDir }) => + Effect.gen(function* rejectsReactDom() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/components/uses-react-dom.ts", + 'import "react-dom";\nexport {};\n', + ); + + const errors = yield* buildErrors(voidhashDir, entry, { + external: [...EXTERNALS, "react-dom"], + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"react-dom"'); + expect(errors[0]).toContain("uses-react-dom.ts"); + }), + )); + + it("rejects arbitrary packages", () => + withFixture(({ projectRoot, voidhashDir }) => + Effect.gen(function* rejectsArbitraryPackages() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/components/uses-lodash.ts", + 'import "lodash";\nexport {};\n', + ); + + const errors = yield* buildErrors(voidhashDir, entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"lodash"'); + }), + )); + + it("rejects the Node-only @voidhash/paywalls/tree entry", () => + withFixture(({ projectRoot, voidhashDir }) => + Effect.gen(function* rejectsTreeEntry() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/components/uses-tree.ts", + 'import "@voidhash/paywalls/tree";\nexport {};\n', + ); + + const errors = yield* buildErrors(voidhashDir, entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"@voidhash/paywalls/tree"'); + }), + )); + + it("rejects relative imports escaping .voidhash", () => + withFixture(({ projectRoot, voidhashDir }) => + Effect.gen(function* rejectsEscapingImports() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/components/escapes.ts", + 'import "../../app-code";\nexport {};\n', + ); + + const errors = yield* buildErrors(voidhashDir, entry); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("escapes the .voidhash directory"); + }), + )); + + it("does not constrain imports made outside .voidhash (node_modules)", () => + withFixture(({ projectRoot, voidhashDir }) => + Effect.gen(function* allowsOutsideVoidhash() { + const path = yield* Path.Path; + const entry = path.join(projectRoot, "vendor-entry.ts"); + yield* writeSource(projectRoot, "vendor-entry.ts", 'import "react-dom";\nexport {};\n'); + + expect( + yield* buildErrors(voidhashDir, entry, { external: [...EXTERNALS, "react-dom"] }), + ).toEqual([]); + }), + )); }); diff --git a/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts b/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts index cc297bce8..f41b662cc 100644 --- a/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts +++ b/apps/cli/tests/domain/services/paywall-deploy-upload.test.ts @@ -1,11 +1,10 @@ import { createHash } from "node:crypto"; -import { promises as fsp } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Schema } from "effect"; +import { NodeServices } from "@effect/platform-node"; +import { constant } from "@voidhash/lib/lang"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { type DeployManifest, @@ -18,12 +17,12 @@ import { uploadPaywallDeploy, } from "../../../src/domain/services/paywall-deploy-upload"; -let projectRoot: string; -let manifest: DeployManifest; - const sha256Hex = (data: string): string => createHash("sha256").update(data).digest("hex"); -const FILES = { +/** Serializes a stub response body exactly as the server would. */ +const jsonBody = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +const FILES = constant({ config: { contents: "export default {};\n", path: "voidhash.config.ts" }, html: { contents: "\n", @@ -37,7 +36,7 @@ const FILES = { contents: "export default null;\n", path: ".voidhash/paywalls/onboarding.tsx", }, -} as const; +}); const hashOf = (file: { contents: string }): string => sha256Hex(file.contents); @@ -81,6 +80,8 @@ const buildManifest = (): DeployManifest => team: "voidhash-dev-sro", }); +const manifest: DeployManifest = buildManifest(); + interface RecordedRequest { readonly method: string; readonly path: string; @@ -102,7 +103,7 @@ const makeStubClient = (options: { Effect.succeed( HttpClientResponse.fromWeb( request, - new Response(JSON.stringify(body), { + new Response(jsonBody(body), { headers: { "content-type": "application/json" }, status, }), @@ -141,21 +142,52 @@ const cliConfigStub: typeof CliConfig.Service = { writeToConfig: () => Effect.void, }; -const runUpload = (client: HttpClient.HttpClient): Promise => - Effect.runPromise( - uploadPaywallDeploy({ manifest, projectRoot }).pipe( - Effect.provideService(HttpClient.HttpClient, client), - Effect.provideService(CliConfig, cliConfigStub), - ), +/** + * Runs `use` against a fresh temporary project holding the manifest's files, + * removed again once the test finishes — the fixture lifecycle + * `beforeAll`/`afterAll` used to own. + */ +const withProjectRoot = ( + use: (projectRoot: string) => Effect.Effect, +): Promise => + Effect.gen(function* withProjectRoot() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectRoot = yield* fs + .makeTempDirectory({ prefix: "voidhash-deploy-upload-" }) + .pipe(Effect.orDie); + + return yield* Effect.gen(function* runFixture() { + for (const file of Object.values(FILES)) { + const abs = path.join(projectRoot, file.path); + yield* fs.makeDirectory(path.dirname(abs), { recursive: true }).pipe(Effect.orDie); + yield* fs.writeFileString(abs, file.contents).pipe(Effect.orDie); + } + return yield* use(projectRoot); + }).pipe( + Effect.ensuring(fs.remove(projectRoot, { force: true, recursive: true }).pipe(Effect.orDie)), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); + +const runUpload = ( + client: HttpClient.HttpClient, + projectRoot: string, +): Effect.Effect => + uploadPaywallDeploy({ manifest, projectRoot }).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(CliConfig, cliConfigStub), + Effect.orDie, ); -const runUploadError = (client: HttpClient.HttpClient): Promise => - Effect.runPromise( - uploadPaywallDeploy({ manifest, projectRoot }).pipe( - Effect.flip, - Effect.provideService(HttpClient.HttpClient, client), - Effect.provideService(CliConfig, cliConfigStub), - ), +const runUploadError = ( + client: HttpClient.HttpClient, + projectRoot: string, +): Effect.Effect => + uploadPaywallDeploy({ manifest, projectRoot }).pipe( + Effect.flip, + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(CliConfig, cliConfigStub), + Effect.orDie, ); const readyFinalizeBody = { @@ -165,91 +197,93 @@ const readyFinalizeBody = { status: "ready", }; -beforeAll(async () => { - projectRoot = await fsp.mkdtemp(join(tmpdir(), "voidhash-deploy-upload-")); - for (const file of Object.values(FILES)) { - const abs = join(projectRoot, file.path); - await fsp.mkdir(join(abs, ".."), { recursive: true }); - await fsp.writeFile(abs, file.contents); - } - manifest = buildManifest(); -}); - -afterAll(async () => { - await fsp.rm(projectRoot, { force: true, recursive: true }); -}); - describe("uploadPaywallDeploy finalize-409 retry", () => { - it("uploads the 409 missing blobs and retries finalize once", async () => { - const requests: RecordedRequest[] = []; - const result = await runUpload( - makeStubClient({ - createMissing: [hashOf(FILES.js)], - finalizeResponses: [ - { body: { missing: [hashOf(FILES.html)] }, status: 409 }, - { body: readyFinalizeBody, status: 200 }, - ], - requests, + it("uploads the 409 missing blobs and retries finalize once", () => + withProjectRoot((projectRoot) => + Effect.gen(function* retriesFinalizeOnce() { + const requests: RecordedRequest[] = []; + const result = yield* runUpload( + makeStubClient({ + createMissing: [hashOf(FILES.js)], + finalizeResponses: [ + { body: { missing: [hashOf(FILES.html)] }, status: 409 }, + { body: readyFinalizeBody, status: 200 }, + ], + requests, + }), + projectRoot, + ); + + expect(result.finalize.status).toBe("ready"); + // One blob from create's missing list + one re-uploaded after the 409. + expect(result.uploadedCount).toBe(2); + const puts = requests.filter((r) => r.method === "PUT"); + expect(puts.map((r) => r.path)).toEqual([ + `/api/v1/paywall-deploys/pw_dep_test/blobs/${hashOf(FILES.js)}`, + `/api/v1/paywall-deploys/pw_dep_test/blobs/${hashOf(FILES.html)}`, + ]); + expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(2); }), - ); + )); - expect(result.finalize.status).toBe("ready"); - // One blob from create's missing list + one re-uploaded after the 409. - expect(result.uploadedCount).toBe(2); - const puts = requests.filter((r) => r.method === "PUT"); - expect(puts.map((r) => r.path)).toEqual([ - `/api/v1/paywall-deploys/pw_dep_test/blobs/${hashOf(FILES.js)}`, - `/api/v1/paywall-deploys/pw_dep_test/blobs/${hashOf(FILES.html)}`, - ]); - expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(2); - }); + it("retries at most once and fails readably when finalize stays 409", () => + withProjectRoot((projectRoot) => + Effect.gen(function* failsAfterOneRetry() { + const requests: RecordedRequest[] = []; + const error = yield* runUploadError( + makeStubClient({ + createMissing: [], + finalizeResponses: [ + { body: { missing: [hashOf(FILES.html)] }, status: 409 }, + { body: { missing: [hashOf(FILES.html)] }, status: 409 }, + ], + requests, + }), + projectRoot, + ); - it("retries at most once and fails readably when finalize stays 409", async () => { - const requests: RecordedRequest[] = []; - const error = await runUploadError( - makeStubClient({ - createMissing: [], - finalizeResponses: [ - { body: { missing: [hashOf(FILES.html)] }, status: 409 }, - { body: { missing: [hashOf(FILES.html)] }, status: 409 }, - ], - requests, + expect(error._tag).toBe("PaywallDeployUploadError"); + expect(error.message).toContain("Finalizing the deploy failed"); + expect(error.message).toContain(hashOf(FILES.html)); + expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(2); }), - ); + )); - expect(error._tag).toBe("PaywallDeployUploadError"); - expect(error.message).toContain("Finalizing the deploy failed"); - expect(error.message).toContain(hashOf(FILES.html)); - expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(2); - }); + it("fails without retrying when the 409 carries no usable missing list", () => + withProjectRoot((projectRoot) => + Effect.gen(function* failsWithoutUsableMissingList() { + const requests: RecordedRequest[] = []; + const error = yield* runUploadError( + makeStubClient({ + createMissing: [], + finalizeResponses: [{ body: { error: "incomplete" }, status: 409 }], + requests, + }), + projectRoot, + ); - it("fails without retrying when the 409 carries no usable missing list", async () => { - const requests: RecordedRequest[] = []; - const error = await runUploadError( - makeStubClient({ - createMissing: [], - finalizeResponses: [{ body: { error: "incomplete" }, status: 409 }], - requests, + expect(error._tag).toBe("PaywallDeployUploadError"); + expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(1); + expect(requests.filter((r) => r.method === "PUT")).toHaveLength(0); }), - ); + )); - expect(error._tag).toBe("PaywallDeployUploadError"); - expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(1); - expect(requests.filter((r) => r.method === "PUT")).toHaveLength(0); - }); + it("fails without retrying when a 409 hash is not part of the manifest", () => + withProjectRoot((projectRoot) => + Effect.gen(function* failsOnForeignHash() { + const requests: RecordedRequest[] = []; + const error = yield* runUploadError( + makeStubClient({ + createMissing: [], + finalizeResponses: [{ body: { missing: ["f".repeat(64)] }, status: 409 }], + requests, + }), + projectRoot, + ); - it("fails without retrying when a 409 hash is not part of the manifest", async () => { - const requests: RecordedRequest[] = []; - const error = await runUploadError( - makeStubClient({ - createMissing: [], - finalizeResponses: [{ body: { missing: ["f".repeat(64)] }, status: 409 }], - requests, + expect(error._tag).toBe("PaywallDeployUploadError"); + expect(error.message).toContain("f".repeat(64)); + expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(1); }), - ); - - expect(error._tag).toBe("PaywallDeployUploadError"); - expect(error.message).toContain("f".repeat(64)); - expect(requests.filter((r) => r.path.endsWith("/finalize"))).toHaveLength(1); - }); + )); }); diff --git a/apps/cli/tests/domain/services/paywall-typecheck.test.ts b/apps/cli/tests/domain/services/paywall-typecheck.test.ts index 20d2b3771..5051b5788 100644 --- a/apps/cli/tests/domain/services/paywall-typecheck.test.ts +++ b/apps/cli/tests/domain/services/paywall-typecheck.test.ts @@ -1,9 +1,6 @@ -import { promises as fsp } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { Effect } from "effect"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Path } from "effect"; +import { describe, expect, it } from "vitest"; import { PAYWALL_ASSET_EXTENSIONS, @@ -11,91 +8,124 @@ import { typecheckPaywallSources, } from "../../../src/domain/services/paywall-typecheck"; -let projectRoot: string; const compilerTestTimeout = 60_000; -const writeSource = async (relPath: string, contents: string) => { - const abs = join(projectRoot, relPath); - await fsp.mkdir(join(abs, ".."), { recursive: true }); - await fsp.writeFile(abs, contents); - return abs; -}; - -beforeAll(async () => { - projectRoot = await fsp.mkdtemp(join(tmpdir(), "voidhash-typecheck-")); -}); +const writeSource = ( + projectRoot: string, + relPath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* writeSource() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const abs = path.join(projectRoot, relPath); + yield* fs.makeDirectory(path.dirname(abs), { recursive: true }); + yield* fs.writeFileString(abs, contents); + return abs; + }).pipe(Effect.orDie); -afterAll(async () => { - await fsp.rm(projectRoot, { force: true, recursive: true }); -}); +/** + * Runs `use` against a fresh temporary project root, removed again once the + * test finishes — the fixture lifecycle `beforeAll`/`afterAll` used to own. + */ +const withProjectRoot = ( + use: (projectRoot: string) => Effect.Effect, +): Promise => + Effect.gen(function* withProjectRoot() { + const fs = yield* FileSystem.FileSystem; + const projectRoot = yield* fs.makeTempDirectory({ prefix: "voidhash-typecheck-" }).pipe( + Effect.orDie, + ); + return yield* use(projectRoot).pipe( + Effect.ensuring(fs.remove(projectRoot, { force: true, recursive: true }).pipe(Effect.orDie)), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); describe("typecheckPaywallSources", () => { it( "passes a source importing a .png via the injected asset declarations", - async () => { - const entry = await writeSource( - ".voidhash/paywalls/with-asset.ts", - ['import hero from "./hero.png";', "export const heroUrl: string = hero;", ""].join("\n"), - ); + () => + withProjectRoot((projectRoot) => + Effect.gen(function* passesAssetImport() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/paywalls/with-asset.ts", + ['import hero from "./hero.png";', "export const heroUrl: string = hero;", ""].join( + "\n", + ), + ); - await expect( - Effect.runPromise(typecheckPaywallSources({ files: [entry], projectRoot })), - ).resolves.toBeUndefined(); - }, + const result = yield* typecheckPaywallSources({ files: [entry], projectRoot }); + expect(result).toBeUndefined(); + }), + ), compilerTestTimeout, ); it( "covers every esbuild-supported asset extension", - async () => { - const imports = PAYWALL_ASSET_EXTENSIONS.map( - (ext, i) => `import asset${i} from "./asset.${ext}";`, - ); - const uses = PAYWALL_ASSET_EXTENSIONS.map( - (_, i) => `export const url${i}: string = asset${i};`, - ); - const entry = await writeSource( - ".voidhash/paywalls/all-assets.ts", - [...imports, ...uses, ""].join("\n"), - ); + () => + withProjectRoot((projectRoot) => + Effect.gen(function* coversEveryExtension() { + const imports = PAYWALL_ASSET_EXTENSIONS.map( + (ext, i) => `import asset${i} from "./asset.${ext}";`, + ); + const uses = PAYWALL_ASSET_EXTENSIONS.map( + (_, i) => `export const url${i}: string = asset${i};`, + ); + const entry = yield* writeSource( + projectRoot, + ".voidhash/paywalls/all-assets.ts", + [...imports, ...uses, ""].join("\n"), + ); - await expect( - Effect.runPromise(typecheckPaywallSources({ files: [entry], projectRoot })), - ).resolves.toBeUndefined(); - }, + const result = yield* typecheckPaywallSources({ files: [entry], projectRoot }); + expect(result).toBeUndefined(); + }), + ), compilerTestTimeout, ); it( "still fails a genuinely type-broken source", - async () => { - const entry = await writeSource( - ".voidhash/paywalls/broken.ts", - ['import hero from "./hero.png";', "export const broken: number = hero;", ""].join("\n"), - ); + () => + withProjectRoot((projectRoot) => + Effect.gen(function* failsBrokenSource() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/paywalls/broken.ts", + ['import hero from "./hero.png";', "export const broken: number = hero;", ""].join( + "\n", + ), + ); - const error = await Effect.runPromise( - typecheckPaywallSources({ files: [entry], projectRoot }).pipe(Effect.flip), - ); - expect(error).toBeInstanceOf(PaywallTypecheckError); - expect(error.message).toContain("broken.ts"); - }, + const error = yield* Effect.flip( + typecheckPaywallSources({ files: [entry], projectRoot }), + ); + expect(error).toBeInstanceOf(PaywallTypecheckError); + expect(error.message).toContain("broken.ts"); + }), + ), compilerTestTimeout, ); it( "still fails an import of an undeclared module kind", - async () => { - const entry = await writeSource( - ".voidhash/paywalls/bad-import.ts", - ['import data from "./data.bin";', "export const d = data;", ""].join("\n"), - ); + () => + withProjectRoot((projectRoot) => + Effect.gen(function* failsUndeclaredModule() { + const entry = yield* writeSource( + projectRoot, + ".voidhash/paywalls/bad-import.ts", + ['import data from "./data.bin";', "export const d = data;", ""].join("\n"), + ); - const error = await Effect.runPromise( - typecheckPaywallSources({ files: [entry], projectRoot }).pipe(Effect.flip), - ); - expect(error).toBeInstanceOf(PaywallTypecheckError); - }, + const error = yield* Effect.flip( + typecheckPaywallSources({ files: [entry], projectRoot }), + ); + expect(error).toBeInstanceOf(PaywallTypecheckError); + }), + ), compilerTestTimeout, ); }); diff --git a/apps/mimic-admin/package.json b/apps/mimic-admin/package.json index 23e072d21..ccfe683eb 100644 --- a/apps/mimic-admin/package.json +++ b/apps/mimic-admin/package.json @@ -41,10 +41,12 @@ "@tanstack/react-query": "^5.80.7", "@tanstack/react-query-devtools": "^5.80.7", "@tanstack/react-router": "1.163.3", + "@voidhash/lib": "workspace:*", "@voidhash/mimic-core": "workspace:*", "@voidhash/mimic-server": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "effect": "catalog:", "lucide-react": "^0.513.0", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/apps/mimic-admin/src/components/app-sidebar.tsx b/apps/mimic-admin/src/components/app-sidebar.tsx index ba2431180..acc874355 100644 --- a/apps/mimic-admin/src/components/app-sidebar.tsx +++ b/apps/mimic-admin/src/components/app-sidebar.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { Link, useMatchRoute } from "@tanstack/react-router"; import { Activity, Database, FileText, LogOut, Users } from "lucide-react"; +import { constant } from "@voidhash/lib/lang"; import { useAuth } from "@/components/auth-context"; import { useDatabase } from "@/components/database-context"; @@ -16,6 +17,13 @@ import { } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { collectionsQuery, databasesQuery } from "@/lib/queries"; +import { cn } from "@/lib/utils"; + +const NAV_ITEMS = constant([ + { to: "/databases", label: "Databases", icon: Database }, + { to: "/users", label: "Users", icon: Users }, + { to: "/observability", label: "Observability", icon: Activity }, +]); export function AppSidebar() { const { credentials, logout } = useAuth(); @@ -26,12 +34,6 @@ export function AppSidebar() { const { data: databases } = useQuery(databasesQuery(sdk)); const { data: collections } = useQuery(collectionsQuery(sdk, selectedDatabaseId ?? "")); - const navItems = [ - { to: "/databases" as const, label: "Databases", icon: Database }, - { to: "/users" as const, label: "Users", icon: Users }, - { to: "/observability" as const, label: "Observability", icon: Activity }, - ]; - return (
@@ -62,17 +64,17 @@ export function AppSidebar() {
@@ -212,9 +240,7 @@ function DocumentPage() { setGrantPerm(v as "read" | "write" | "admin")} + onValueChange={(v) => setGrantPerm(decodePermission(v))} > diff --git a/apps/mimic-admin/src/routes/_app/route.tsx b/apps/mimic-admin/src/routes/_app/route.tsx index ca39a84b2..cc417912d 100644 --- a/apps/mimic-admin/src/routes/_app/route.tsx +++ b/apps/mimic-admin/src/routes/_app/route.tsx @@ -1,4 +1,5 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { Effect } from "effect"; import { AuthProvider } from "@/components/auth-context"; import { MimicSdkProvider } from "@/components/sdk-context"; @@ -8,7 +9,9 @@ export const Route = createFileRoute("/_app")({ beforeLoad: () => { const credentials = getCredentials(); if (!credentials) { - throw redirect({ to: "/login" }); + // TanStack Router signals navigation by a thrown redirect; `runSync` on a + // defect rethrows the redirect object verbatim so the router still sees it. + return Effect.runSync(Effect.die(redirect({ to: "/login" }))); } return { credentials }; }, diff --git a/apps/mimic-admin/src/routes/login.tsx b/apps/mimic-admin/src/routes/login.tsx index 3bb809e79..d532d755c 100644 --- a/apps/mimic-admin/src/routes/login.tsx +++ b/apps/mimic-admin/src/routes/login.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { MimicSDK } from "@voidhash/mimic-server"; +import { Data, Effect } from "effect"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -13,6 +14,22 @@ export const Route = createFileRoute("/login")({ component: LoginPage, }); +class ConnectionFailedError extends Data.TaggedError("ConnectionFailedError")<{ + readonly message: string; +}> {} + +/** Extracts the operator-facing reason from an unknown connection failure. */ +function connectionFailureReason(cause: unknown): string { + if (cause instanceof Error) return cause.message; + return "Unknown error"; +} + +/** Label for the connect submit button. */ +function connectLabel(isLoading: boolean): string { + if (isLoading) return "Connecting..."; + return "Connect"; +} + function LoginPage() { const navigate = useNavigate(); const [serverUrl, setServerUrl] = useState("http://localhost:5001"); @@ -20,7 +37,7 @@ function LoginPage() { const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); - async function handleSubmit(e: React.FormEvent) { + function handleSubmit(e: React.FormEvent) { e.preventDefault(); setLoading(true); @@ -34,16 +51,33 @@ function LoginPage() { password: creds.password, }); - try { - await sdk.listDatabases(); - setCredentials(creds); - navigate({ to: "/" }); - } catch (err) { - toast.error(`Connection failed: ${err instanceof Error ? err.message : "Unknown error"}`); - } finally { - void sdk.dispose(); - setLoading(false); - } + const connect = Effect.gen(function* () { + yield* Effect.tryPromise({ + try: () => sdk.listDatabases(), + catch: (cause) => new ConnectionFailedError({ message: connectionFailureReason(cause) }), + }); + yield* Effect.try({ + try: () => { + setCredentials(creds); + void navigate({ to: "/" }); + }, + catch: (cause) => new ConnectionFailedError({ message: connectionFailureReason(cause) }), + }); + }).pipe( + Effect.catchTag("ConnectionFailedError", (error) => + Effect.sync(() => { + toast.error(`Connection failed: ${error.message}`); + }), + ), + Effect.ensuring( + Effect.sync(() => { + void sdk.dispose(); + setLoading(false); + }), + ), + ); + + void Effect.runPromise(connect); } return ( @@ -86,7 +120,7 @@ function LoginPage() { /> diff --git a/apps/mimic-db/package.json b/apps/mimic-db/package.json index a66e8f2be..ca2fda777 100644 --- a/apps/mimic-db/package.json +++ b/apps/mimic-db/package.json @@ -24,6 +24,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@effect/sql-pg": "catalog:", + "@voidhash/lib": "workspace:*", "@voidhash/mimic-core": "workspace:*", "@voidhash/mimic-server": "workspace:*", "@voidhash/platform": "workspace:*", diff --git a/apps/mimic-db/src/api/handlers/databases.ts b/apps/mimic-db/src/api/handlers/databases.ts index 46936bf00..e72a373e2 100644 --- a/apps/mimic-db/src/api/handlers/databases.ts +++ b/apps/mimic-db/src/api/handlers/databases.ts @@ -3,15 +3,15 @@ import { CurrentUser, DatabasesRpcs, ForbiddenError } from "@voidhash/mimic-serv import { HostServiceTag } from "../../app/hostService.ts"; -const requireSuperuser = (user: { isSuperuser: boolean }, action: string) => - user.isSuperuser - ? Effect.void - : Effect.fail( - new ForbiddenError({ - code: "forbidden", - message: `Superuser permission required for ${action}`, - }), - ); +const requireSuperuser = (user: { isSuperuser: boolean }, action: string) => { + if (user.isSuperuser) return Effect.void; + return Effect.fail( + new ForbiddenError({ + code: "forbidden", + message: `Superuser permission required for ${action}`, + }), + ); +}; export const DatabasesHandlersLive = DatabasesRpcs.toLayer( Effect.gen(function* () { diff --git a/apps/mimic-db/src/api/handlers/document-auth.ts b/apps/mimic-db/src/api/handlers/document-auth.ts index 82b9cacf0..3385369c5 100644 --- a/apps/mimic-db/src/api/handlers/document-auth.ts +++ b/apps/mimic-db/src/api/handlers/document-auth.ts @@ -21,8 +21,15 @@ const parseAbsoluteUrl = (value: string) => { }; }; +const websocketProtocol = (protocol: string): string => { + if (protocol === "https") return "wss"; + return "ws"; +}; + /** - * Builds the absolute `ws(s)://` URL a client connects to for a document. + * Builds the absolute `ws(s)://` URL a client connects to for a document, or + * `undefined` when neither the configured base URL nor the request identifies + * a host (a defect the caller reports). * * `publicBaseUrl` (the `MIMIC_PUBLIC_BASE_URL` config) is the primary * authority: requests arriving through a service-binding fetch carry no @@ -37,14 +44,15 @@ export const buildDocumentConnectionUrl = ( databaseId: string, collectionId: string, documentId: string, -) => { +): string | undefined => { const path = `/ws/v1/databases/${encodeURIComponent( databaseId, )}/collections/${encodeURIComponent(collectionId)}/documents/${encodeURIComponent(documentId)}`; - const base = publicBaseUrl ? parseAbsoluteUrl(publicBaseUrl) : null; - if (base) { - const wsProtocol = base.protocol === "https" ? "wss" : "ws"; - return `${wsProtocol}://${base.host}${path}`; + if (publicBaseUrl) { + const base = parseAbsoluteUrl(publicBaseUrl); + if (base) { + return `${websocketProtocol(base.protocol)}://${base.host}${path}`; + } } const forwardedProto = getHeader(request.headers, "x-forwarded-proto"); const forwardedHost = getHeader(request.headers, "x-forwarded-host"); @@ -53,10 +61,9 @@ export const buildDocumentConnectionUrl = ( const protocol = absoluteUrl?.protocol ?? forwardedProto ?? "http"; const authority = absoluteUrl?.host ?? host; if (!authority) { - throw new Error("Failed to determine request host for document connection URL"); + return undefined; } - const wsProtocol = protocol === "https" ? "wss" : "ws"; - return `${wsProtocol}://${authority}${path}`; + return `${websocketProtocol(protocol)}://${authority}${path}`; }; export const DocumentAuthHandlersLive = DocumentAuthRpcs.toLayer( @@ -82,16 +89,19 @@ export const DocumentAuthHandlersLive = DocumentAuthRpcs.toLayer( origins, expiresInSeconds, ); - return { - token: result.token, - url: buildDocumentConnectionUrl( - getConfig().publicBaseUrl, - request, - databaseId, - collectionId, - documentId, - ), - }; + const url = buildDocumentConnectionUrl( + getConfig().publicBaseUrl, + request, + databaseId, + collectionId, + documentId, + ); + if (url === undefined) { + return yield* Effect.die( + new Error("Failed to determine request host for document connection URL"), + ); + } + return { token: result.token, url }; }), }; }), diff --git a/apps/mimic-db/src/api/handlers/documents.ts b/apps/mimic-db/src/api/handlers/documents.ts index 02b749370..e31a08cc3 100644 --- a/apps/mimic-db/src/api/handlers/documents.ts +++ b/apps/mimic-db/src/api/handlers/documents.ts @@ -2,7 +2,10 @@ import { Effect } from "effect"; import { CurrentUser, DocumentsRpcs } from "@voidhash/mimic-server/rpc"; import { HostServiceTag } from "../../app/hostService.ts"; -import type { TransactionEnvelope } from "../../document/transaction.ts"; +import { + decodeDocumentValue, + decodeTransactionEnvelope, +} from "../../document/transaction.ts"; export const DocumentsHandlersLive = DocumentsRpcs.toLayer( Effect.gen(function* () { @@ -34,14 +37,13 @@ export const DocumentsHandlersLive = DocumentsRpcs.toLayer( const user = yield* CurrentUser; const databaseId = yield* host.databaseIdForCollection(collectionId); yield* host.ensureDatabasePermission(user.userId, user.isSuperuser, databaseId, "write"); - // The wire schema treats commands as `Schema.Unknown[]`; the host - // service is typed against the structured `Command[]` shape from - // mimic-core. The host validates the command shape internally as - // it applies them, so casting here is safe. + // The wire schema treats commands as opaque JSON; the host service is + // typed against the structured `Command[]` shape from mimic-core and + // validates the command shape internally as it applies them. return yield* host.submitTransaction( collectionId, documentId, - transaction as TransactionEnvelope, + decodeTransactionEnvelope(transaction), ); }), OpenDocumentConnection: ({ collectionId, documentId, connectionId, presence, leaseMs }) => @@ -55,7 +57,7 @@ export const DocumentsHandlersLive = DocumentsRpcs.toLayer( connectionId, "write", user.userId, - presence as never, + decodeDocumentValue(presence), leaseMs, ); return { id: documentId, collectionId, value: snapshot.value, version: snapshot.version }; @@ -96,7 +98,7 @@ export const DocumentsHandlersLive = DocumentsRpcs.toLayer( collectionId, documentId, connectionId, - transaction as TransactionEnvelope, + decodeTransactionEnvelope(transaction), leaseMs, ); }), diff --git a/apps/mimic-db/src/api/handlers/grants.ts b/apps/mimic-db/src/api/handlers/grants.ts index 822fdd3b8..54a7117f9 100644 --- a/apps/mimic-db/src/api/handlers/grants.ts +++ b/apps/mimic-db/src/api/handlers/grants.ts @@ -3,15 +3,15 @@ import { CurrentUser, ForbiddenError, GrantsRpcs } from "@voidhash/mimic-server/ import { HostServiceTag } from "../../app/hostService.ts"; -const requireSuperuser = (user: { isSuperuser: boolean }, action: string) => - user.isSuperuser - ? Effect.void - : Effect.fail( - new ForbiddenError({ - code: "forbidden", - message: `Superuser permission required for ${action}`, - }), - ); +const requireSuperuser = (user: { isSuperuser: boolean }, action: string) => { + if (user.isSuperuser) return Effect.void; + return Effect.fail( + new ForbiddenError({ + code: "forbidden", + message: `Superuser permission required for ${action}`, + }), + ); +}; export const GrantsHandlersLive = GrantsRpcs.toLayer( Effect.gen(function* () { diff --git a/apps/mimic-db/src/api/handlers/users.ts b/apps/mimic-db/src/api/handlers/users.ts index 247d1f716..54c16cfcd 100644 --- a/apps/mimic-db/src/api/handlers/users.ts +++ b/apps/mimic-db/src/api/handlers/users.ts @@ -3,15 +3,15 @@ import { CurrentUser, ForbiddenError, UsersRpcs } from "@voidhash/mimic-server/r import { HostServiceTag } from "../../app/hostService.ts"; -const requireSuperuser = (user: { isSuperuser: boolean }, action: string) => - user.isSuperuser - ? Effect.void - : Effect.fail( - new ForbiddenError({ - code: "forbidden", - message: `Superuser permission required for ${action}`, - }), - ); +const requireSuperuser = (user: { isSuperuser: boolean }, action: string) => { + if (user.isSuperuser) return Effect.void; + return Effect.fail( + new ForbiddenError({ + code: "forbidden", + message: `Superuser permission required for ${action}`, + }), + ); +}; export const UsersHandlersLive = UsersRpcs.toLayer( Effect.gen(function* () { diff --git a/apps/mimic-db/src/api/middleware/auth.ts b/apps/mimic-db/src/api/middleware/auth.ts index 1a7b6e69f..89587c9c3 100644 --- a/apps/mimic-db/src/api/middleware/auth.ts +++ b/apps/mimic-db/src/api/middleware/auth.ts @@ -10,28 +10,31 @@ interface BasicCredentials { readonly password: string; } -const parseBasicAuth = (header: string | undefined): BasicCredentials => { - if (!header?.startsWith("Basic ")) { - throw new UnauthorizedError({ - code: "unauthorized", - message: "Authentication required. Provide Authorization: Basic header.", - }); - } - - const decoded = Buffer.from(header.slice(6), "base64").toString("utf8"); - const separator = decoded.indexOf(":"); - if (separator <= 0) { - throw new UnauthorizedError({ - code: "unauthorized", - message: "Invalid Basic auth header format", - }); - } - - return { - username: decoded.slice(0, separator), - password: decoded.slice(separator + 1), - }; -}; +const parseBasicAuth = ( + header: string | undefined, +): Effect.Effect => + Effect.gen(function* () { + if (!header?.startsWith("Basic ")) { + return yield* new UnauthorizedError({ + code: "unauthorized", + message: "Authentication required. Provide Authorization: Basic header.", + }); + } + + const decoded = Buffer.from(header.slice(6), "base64").toString("utf8"); + const separator = decoded.indexOf(":"); + if (separator <= 0) { + return yield* new UnauthorizedError({ + code: "unauthorized", + message: "Invalid Basic auth header format", + }); + } + + return { + username: decoded.slice(0, separator), + password: decoded.slice(separator + 1), + }; + }); /** * Server-side implementation of `AuthMiddleware`. @@ -48,10 +51,8 @@ export const AuthMiddlewareLive = Layer.effect(AuthMiddleware)( return (effect, { headers }) => Effect.gen(function* () { - const auth = - (headers as Record)["authorization"] ?? - (headers as Record)["Authorization"]; - const { username, password } = yield* Effect.sync(() => parseBasicAuth(auth)); + const auth = headers["authorization"] ?? headers["Authorization"]; + const { username, password } = yield* parseBasicAuth(auth); const user = yield* host.authenticateBasic(username, password); return yield* Effect.provideService(effect, CurrentUser, user); }); diff --git a/apps/mimic-db/src/config.ts b/apps/mimic-db/src/config.ts index 134e7212f..05552bd84 100644 --- a/apps/mimic-db/src/config.ts +++ b/apps/mimic-db/src/config.ts @@ -1,3 +1,5 @@ +import { constant } from "@voidhash/lib/lang"; + /** * Runtime configuration for mimic-db. * @@ -33,15 +35,16 @@ export interface MimicConfig { const positiveInt = (value: string | undefined, fallback: number): number => { if (!value || value.trim() === "") return fallback; const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; }; -const DEFAULT_CORS_ORIGINS = [ +const DEFAULT_CORS_ORIGINS = constant([ "http://localhost:5173", "http://localhost:4173", "http://localhost:4460", "http://localhost:3003", -] as const; +]); export const getCorsAllowedOrigins = (): readonly string[] => { const env = process.env.CORS_ORIGINS?.trim(); diff --git a/apps/mimic-db/src/core/control-engine.ts b/apps/mimic-db/src/core/control-engine.ts index c16704e56..4b26169bc 100644 --- a/apps/mimic-db/src/core/control-engine.ts +++ b/apps/mimic-db/src/core/control-engine.ts @@ -8,7 +8,7 @@ import { type DatabasePermission, type DocumentPermission, } from "@voidhash/mimic-server/rpc"; -import { Effect } from "effect"; +import { Clock, Effect } from "effect"; import { normalizeSchemaObject, sanitizeValueForSchema } from "../document/schema.ts"; import { hashHex, randomId } from "./ids.ts"; @@ -24,8 +24,11 @@ const unauthorized = (message: string): UnauthorizedError => const forbidden = (message: string): ForbiddenError => new ForbiddenError({ code: "forbidden", message }); -const permissionRank = (permission: DatabasePermission): number => - permission === "read" ? 1 : permission === "write" ? 2 : 3; +const permissionRank = (permission: DatabasePermission): number => { + if (permission === "read") return 1; + if (permission === "write") return 2; + return 3; +}; interface CollectionView { readonly id: string; @@ -180,15 +183,17 @@ export const makeControlEngine = ( registry: MigrationRegistry = EmptyMigrationRegistry, ): ControlEngineApi => { const findCollection: ControlEngineApi["findCollection"] = (collectionId) => - store - .findCollectionById(collectionId) - .pipe( - Effect.flatMap((record) => - record - ? Effect.succeed(record) - : Effect.fail(notFound(`Collection not found: ${collectionId}`)), - ), - ); + store.findCollectionById(collectionId).pipe( + Effect.flatMap((record) => { + if (!record) return Effect.fail(notFound(`Collection not found: ${collectionId}`)); + return Effect.succeed(record); + }), + ); + + const listGrantRows = (userId: string | undefined) => { + if (!userId) return store.listGrants(); + return store.listGrantsByUser(userId); + }; return { store, @@ -207,33 +212,35 @@ export const makeControlEngine = ( authenticateBasic: (username, password) => store.findUserByUsername(username).pipe( - Effect.flatMap((user) => - !user || user.passwordHash !== hashHex(password) - ? Effect.fail(unauthorized("Invalid credentials")) - : Effect.succeed({ - userId: user.id, - username: user.username, - isSuperuser: user.isSuperuser, - }), - ), + Effect.flatMap((user) => { + if (!user || user.passwordHash !== hashHex(password)) { + return Effect.fail(unauthorized("Invalid credentials")); + } + return Effect.succeed({ + userId: user.id, + username: user.username, + isSuperuser: user.isSuperuser, + }); + }), ), authenticateDocumentToken: (token, collectionId, documentId, origin) => Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; const record = yield* store.findTokenByHash(hashHex(token)); if ( !record || record.collectionId !== collectionId || record.documentId !== documentId || record.usedAt !== null || - record.expiresAtMs < Date.now() + record.expiresAtMs < now ) { return yield* Effect.fail(unauthorized("Invalid document token")); } if (record.origins.length > 0 && origin !== null && !record.origins.includes(origin)) { return yield* Effect.fail(unauthorized("Document token origin is not allowed")); } - yield* store.markTokenUsed(record.id, Date.now()); + yield* store.markTokenUsed(record.id, now); return { tokenId: record.id, permission: record.permission }; }), @@ -337,13 +344,12 @@ export const makeControlEngine = ( ), deleteUser: (userId) => - store - .findUserById(userId) - .pipe( - Effect.flatMap((user) => - user ? store.deleteUser(userId) : Effect.fail(notFound(`User not found: ${userId}`)), - ), - ), + store.findUserById(userId).pipe( + Effect.flatMap((user) => { + if (!user) return Effect.fail(notFound(`User not found: ${userId}`)); + return store.deleteUser(userId); + }), + ), grantPermission: (userId, databaseId, permission) => Effect.gen(function* () { @@ -355,20 +361,17 @@ export const makeControlEngine = ( }), revokePermission: (userId, databaseId) => - store - .findGrant(userId, databaseId) - .pipe( - Effect.flatMap((grant) => - grant - ? store.removeGrant(userId, databaseId) - : Effect.fail( - notFound(`Grant not found for user ${userId} on database ${databaseId}`), - ), - ), - ), + store.findGrant(userId, databaseId).pipe( + Effect.flatMap((grant) => { + if (!grant) { + return Effect.fail(notFound(`Grant not found for user ${userId} on database ${databaseId}`)); + } + return store.removeGrant(userId, databaseId); + }), + ), listGrants: (userId) => - (userId ? store.listGrantsByUser(userId) : store.listGrants()).pipe( + listGrantRows(userId).pipe( Effect.map((rows) => rows.map((row) => ({ id: row.id, @@ -386,6 +389,7 @@ export const makeControlEngine = ( if (!index || index.collectionId !== collectionId || index.deletedAt !== null) { return yield* Effect.fail(notFound(`Document not found: ${documentId}`)); } + const now = yield* Clock.currentTimeMillis; const token = randomId(); yield* store.createToken({ id: randomId(), @@ -394,7 +398,7 @@ export const makeControlEngine = ( documentId, permission, origins, - expiresAtMs: Date.now() + (expiresInSeconds ?? 300) * 1000, + expiresAtMs: now + (expiresInSeconds ?? 300) * 1000, usedAt: null, }); return { token }; @@ -432,8 +436,10 @@ export const makeControlEngine = ( // When the caller can prove the object is unmaterialized, re-seed // it (fall through to registerDocument, which the caller pairs with // a fresh document-object create) instead of conflicting. - const materialized = - isMaterialized === undefined ? true : yield* isMaterialized(documentId); + if (isMaterialized === undefined) { + return yield* Effect.fail(conflict(`Document '${documentId}' already exists`)); + } + const materialized = yield* isMaterialized(documentId); if (materialized) { return yield* Effect.fail(conflict(`Document '${documentId}' already exists`)); } @@ -464,15 +470,14 @@ export const makeControlEngine = ( }), findDocument: (collectionId, documentId) => - store - .findDocumentIndex(documentId) - .pipe( - Effect.flatMap((index) => - index && index.collectionId === collectionId && index.deletedAt === null - ? Effect.void - : Effect.fail(notFound(`Document not found: ${documentId}`)), - ), - ), + store.findDocumentIndex(documentId).pipe( + Effect.flatMap((index) => { + if (index && index.collectionId === collectionId && index.deletedAt === null) { + return Effect.void; + } + return Effect.fail(notFound(`Document not found: ${documentId}`)); + }), + ), listDocumentIds: (collectionId) => findCollection(collectionId).pipe( @@ -480,6 +485,10 @@ export const makeControlEngine = ( Effect.map((rows) => rows.map((row) => row.documentId)), ), - markDocumentDeleted: (documentId) => store.markDocumentDeleted(documentId, Date.now()), + markDocumentDeleted: (documentId) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + yield* store.markDocumentDeleted(documentId, now); + }), }; }; diff --git a/apps/mimic-db/src/core/document-engine.ts b/apps/mimic-db/src/core/document-engine.ts index fd5e765ca..45b9ab3b2 100644 --- a/apps/mimic-db/src/core/document-engine.ts +++ b/apps/mimic-db/src/core/document-engine.ts @@ -2,7 +2,6 @@ import { applyBatch, cloneValue, parseSchema, - type Command, type SchemaObject, type Value, } from "@voidhash/mimic-core"; @@ -12,7 +11,8 @@ import { runDirectMigration, type MigrationRegistry, } from "@voidhash/mimic-server/migrate"; -import { Effect, Result } from "effect"; +import { causeMessage } from "@voidhash/lib/lang"; +import { Clock, Effect, Result } from "effect"; import { sanitizeValueForSchema } from "../document/schema.ts"; import type { SubmitTransactionResponse, TransactionEnvelope } from "../document/transaction.ts"; @@ -103,10 +103,11 @@ export const makeDocumentEngine = (deps: DocumentEngineDeps): DocumentEngineApi newSchema: parseSchema(target.schemaJson), value: current, }); - if (!result.ok || result.value === undefined) { - return yield* Effect.fail( - migrationFailed(result.ok ? "Migration produced an empty value" : result.error.message), - ); + if (!result.ok) { + return yield* Effect.fail(migrationFailed(result.error.message)); + } + if (result.value === undefined) { + return yield* Effect.fail(migrationFailed("Migration produced an empty value")); } current = result.value; current = yield* sanitize(target.schemaJson, current); @@ -120,7 +121,7 @@ export const makeDocumentEngine = (deps: DocumentEngineDeps): DocumentEngineApi ): Effect.Effect => Effect.try({ try: () => sanitizeValueForSchema(schemaJson, value), - catch: (error) => migrationFailed(error instanceof Error ? error.message : String(error)), + catch: (error) => migrationFailed(causeMessage(error)), }); const load: DocumentEngineApi["load"] = () => @@ -168,8 +169,7 @@ export const makeDocumentEngine = (deps: DocumentEngineDeps): DocumentEngineApi for (const migration of definition.migrations.slice(currentMigrationVersion)) { value = yield* Effect.try({ try: () => runDirectMigration(migration, value), - catch: (error) => - migrationFailed(error instanceof Error ? error.message : String(error)), + catch: (error) => migrationFailed(causeMessage(error)), }); migrationVersion = migration.version; changed = true; @@ -217,15 +217,16 @@ export const makeDocumentEngine = (deps: DocumentEngineDeps): DocumentEngineApi const ctx = yield* deps.schema.getCollectionContext(loaded.collectionId); const schemaJson = ctx?.schemaJson; - const commands = envelope.commands as readonly Command[]; + const commands = envelope.commands; const applied = yield* Effect.result( Effect.try({ try: () => { const next = applyBatch(loaded.value, commands); - return schemaJson ? sanitizeValueForSchema(schemaJson, next) : next; + if (!schemaJson) return next; + return sanitizeValueForSchema(schemaJson, next); }, - catch: (error) => (error instanceof Error ? error : new Error(String(error))), + catch: causeMessage, }), ); @@ -234,7 +235,7 @@ export const makeDocumentEngine = (deps: DocumentEngineDeps): DocumentEngineApi accepted: false, version: loaded.version, transactionId, - reason: applied.failure.message, + reason: applied.failure, }; } @@ -254,7 +255,10 @@ export const makeDocumentEngine = (deps: DocumentEngineDeps): DocumentEngineApi const remove: DocumentEngineApi["remove"] = () => Effect.gen(function* () { const meta = yield* store.readMeta(); - if (meta) yield* store.setMeta({ deletedAt: Date.now() }); + if (meta) { + const deletedAt = yield* Clock.currentTimeMillis; + yield* store.setMeta({ deletedAt }); + } }); return { create, load, submit, remove }; diff --git a/apps/mimic-db/src/core/local-entity-host.ts b/apps/mimic-db/src/core/local-entity-host.ts index 00a03d4d6..367ec71a4 100644 --- a/apps/mimic-db/src/core/local-entity-host.ts +++ b/apps/mimic-db/src/core/local-entity-host.ts @@ -52,7 +52,7 @@ export const makeMemoryDurableEntityHost = (): DurableEntityHostShape => { alarm: { get: Effect.sync(() => state.alarm), set: (scheduledTime) => Effect.sync(() => void (state.alarm = scheduledTime)), - delete: Effect.sync(() => void (state.alarm = undefined)), + delete: Effect.sync(() => (state.alarm = undefined)), }, sessions: { get: (sessionId) => Effect.sync(() => state.sessions.get(sessionId)), diff --git a/apps/mimic-db/src/core/local-host-service.ts b/apps/mimic-db/src/core/local-host-service.ts index 8258880e9..4b82ef274 100644 --- a/apps/mimic-db/src/core/local-host-service.ts +++ b/apps/mimic-db/src/core/local-host-service.ts @@ -3,9 +3,10 @@ import { DurableEntityHost, makeDurableEntityAddress, } from "@voidhash/platform/DurableEntity"; -import { Effect, Layer } from "effect"; +import { Clock, Effect, Layer, Predicate } from "effect"; import type { MigrationRegistry } from "@voidhash/mimic-server/migrate"; import { NotFoundError } from "@voidhash/mimic-server/rpc"; +import { constant } from "@voidhash/lib/lang"; import { HostServiceTag, type HostService, type PresenceEntry } from "../app/hostService.ts"; import { getConfig, type MimicConfig } from "../config.ts"; @@ -39,6 +40,22 @@ import { randomId } from "./ids.ts"; const docKeyOf = (collectionId: string, documentId: string): string => `${collectionId}:${documentId}`; +/** + * Whether a websocket session attachment belongs to an authenticated + * collaborator. The entity host types attachments as `unknown`, so the shape is + * narrowed here instead of at every broadcast site. + */ +const isAuthenticatedSession = (attachment: unknown): attachment is SessionAttachment => { + if (!Predicate.hasProperty(attachment, "authenticated")) return false; + return attachment.authenticated === true; +}; + +/** Spreads `userId` into a presence entry only when the connection has one. */ +const optionalUserId = (userId: string | undefined): { readonly userId?: string } => { + if (userId === undefined) return {}; + return { userId }; +}; + interface StoredPresence { readonly entry: PresenceEntry; readonly expiresAt?: number; @@ -124,8 +141,8 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => sessions, (session) => Effect.gen(function* () { - const attachment = (yield* session.getAttachment) as SessionAttachment | undefined; - if (attachment?.authenticated !== true) return; + const attachment = yield* session.getAttachment; + if (!isAuthenticatedSession(attachment)) return; yield* session.send(encodeServerMessage(message)); }), { discard: true }, @@ -144,12 +161,13 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => entries: Map, entity: DurableEntityContext, ) => { - const expirations = [...entries.values()].flatMap(({ expiresAt }) => - expiresAt === undefined ? [] : [expiresAt], - ); - return expirations.length === 0 - ? Effect.void - : scheduleAlarmAt(entity, Math.min(...expirations)); + const expirations: number[] = []; + for (const { expiresAt } of entries.values()) { + if (expiresAt === undefined) continue; + expirations.push(expiresAt); + } + if (expirations.length === 0) return Effect.void; + return scheduleAlarmAt(entity, Math.min(...expirations)); }; const prunePresence = ( @@ -159,7 +177,7 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => ): Effect.Effect => Effect.gen(function* () { const entries = presenceOf(collectionId, documentId); - const now = Date.now(); + const now = yield* Clock.currentTimeMillis; for (const [connectionId, stored] of entries) { if (stored.expiresAt === undefined || stored.expiresAt > now) continue; entries.delete(connectionId); @@ -182,7 +200,8 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => if (current?.expiresAt === undefined) { return yield* Effect.fail(connectionNotFound(connectionId)); } - const next = { ...current, expiresAt: Date.now() + leaseMs }; + const now = yield* Clock.currentTimeMillis; + const next = { ...current, expiresAt: now + leaseMs }; entries.set(connectionId, next); yield* scheduleAlarmAt(entity, next.expiresAt); return next; @@ -276,14 +295,13 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => getDoc(collectionId, documentId) .load() .pipe( - Effect.map( - (loaded) => - ({ - id: documentId, - collectionId, - value: loaded.value, - version: loaded.version, - }) as const, + Effect.map((loaded) => + constant({ + id: documentId, + collectionId, + value: loaded.value, + version: loaded.version, + }), ), ), ), @@ -335,11 +353,12 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => const loaded = yield* getDoc(collectionId, documentId).load(); const entry: PresenceEntry = { data: connectionPresence, - ...(userId === undefined ? {} : { userId }), + ...optionalUserId(userId), }; + const now = yield* Clock.currentTimeMillis; const stored = { entry, - expiresAt: Date.now() + leaseMs, + expiresAt: now + leaseMs, }; presenceOf(collectionId, documentId).set(connectionId, stored); yield* scheduleAlarmAt(entity, stored.expiresAt); @@ -391,7 +410,7 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => ...transaction, actor: { connectionId, - ...(connection.entry.userId === undefined ? {} : { userId: connection.entry.userId }), + ...optionalUserId(connection.entry.userId), }, }; const result = yield* getDoc(collectionId, documentId).submit(envelope); @@ -408,7 +427,8 @@ export const makeLocalHostService = (deps: LocalHostServiceDeps): HostService => const removed = presenceOf(collectionId, documentId).delete(connectionId); if (removed) yield* broadcast(entity, presenceRemoveMessage(connectionId)); if (removed && presenceOf(collectionId, documentId).size === 0) { - yield* scheduleAlarmAt(entity, Date.now() + config.idleNotifyDebounceMs); + const now = yield* Clock.currentTimeMillis; + yield* scheduleAlarmAt(entity, now + config.idleNotifyDebounceMs); } }), ), diff --git a/apps/mimic-db/src/core/memory-store.ts b/apps/mimic-db/src/core/memory-store.ts index 271ac1f93..e8385fdb0 100644 --- a/apps/mimic-db/src/core/memory-store.ts +++ b/apps/mimic-db/src/core/memory-store.ts @@ -149,11 +149,13 @@ export const makeMemoryDocumentStore = (): DocumentStoreApi => { snapshots.push({ seq: 0, value: cloneValue(value), schemaVersion }); }), loadLatestSnapshot: () => - sync(() => - snapshots.length === 0 - ? undefined - : snapshots.reduce((best, row) => (row.seq >= best.seq ? row : best)), - ), + sync(() => { + if (snapshots.length === 0) return undefined; + return snapshots.reduce((best, row) => { + if (row.seq >= best.seq) return row; + return best; + }); + }), listCommandsAfter: (seq) => sync(() => commands.filter((row) => row.seq > seq).sort((a, b) => a.seq - b.seq)), appendCommands: (fromSeq, cmds: readonly Command[], txId) => diff --git a/apps/mimic-db/src/core/migration-registry.ts b/apps/mimic-db/src/core/migration-registry.ts index 01555b095..cb31c05b6 100644 --- a/apps/mimic-db/src/core/migration-registry.ts +++ b/apps/mimic-db/src/core/migration-registry.ts @@ -21,19 +21,28 @@ export const EmptyMigrationRegistryLive = Layer.succeed( EmptyMigrationRegistry, ); -const canonicalize = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(canonicalize); - if (typeof value !== "object" || value === null) return value; - return Object.fromEntries( - Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entry]) => [key, canonicalize(entry)]), - ); +/** + * Structural, key-order-independent equality for the JSON shapes serialized + * schemas are made of. Replaces a canonicalize-then-`JSON.stringify` compare. + */ +const schemasEqual = (left: unknown, right: unknown): boolean => { + if (left === right) return true; + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) return false; + if (left.length !== right.length) return false; + return left.every((entry, index) => schemasEqual(entry, right[index])); + } + if (typeof left !== "object" || typeof right !== "object") return false; + if (left === null || right === null) return false; + const leftEntries = Object.entries(left); + const rightEntries = new Map(Object.entries(right)); + if (leftEntries.length !== rightEntries.size) return false; + return leftEntries.every(([key, entry]) => { + if (!rightEntries.has(key)) return false; + return schemasEqual(entry, rightEntries.get(key)); + }); }; -const schemasEqual = (left: unknown, right: unknown): boolean => - JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right)); - /** Ensures every registry-owned database and collection is present and current. */ export const ensureMigrationRegistry = ( store: ControlStoreApi, diff --git a/apps/mimic-db/src/core/pg-store.ts b/apps/mimic-db/src/core/pg-store.ts index 7169c7194..1cf76ada1 100644 --- a/apps/mimic-db/src/core/pg-store.ts +++ b/apps/mimic-db/src/core/pg-store.ts @@ -1,6 +1,6 @@ import * as PgClient from "@effect/sql-pg/PgClient"; import { validateValue, type Command, type Value } from "@voidhash/mimic-core"; -import { Effect, Predicate, Redacted } from "effect"; +import { Effect, Predicate, Redacted, Schema } from "effect"; import { SqlClient, SqlError } from "effect/unstable/sql"; import type { CommandRow, DocumentMeta, DocumentStoreApi, SnapshotRow } from "./store.ts"; @@ -39,14 +39,31 @@ const clientLayer = (config: PgDocumentConfig) => // `@effect/sql-pg` runs statements through node-postgres's prepared path. +const JsonText = Schema.fromJsonString(Schema.Any); +const parseJsonText = Schema.decodeUnknownSync(JsonText); +const formatJsonText = Schema.encodeSync(JsonText); +const acceptAny = Schema.decodeUnknownSync(Schema.Any); + +/** + * Reads a `jsonb` column. node-postgres hands back either an already-parsed + * object or the raw JSON text depending on the driver's type parsers, so both + * shapes are normalised here through the same JSON codec. + */ +const decodeJsonColumn =
(input: unknown): A => { + if (Predicate.isString(input)) return parseJsonText(input); + return acceptAny(input); +}; + +/** Renders a value as the JSON text bound to a `jsonb` parameter. */ +const encodeJsonColumn = (value: unknown): string => formatJsonText(value); + const decodeValue = (input: unknown): Value => { - const decoded = typeof input === "string" ? (JSON.parse(input) as Value) : (input as Value); + const decoded = decodeJsonColumn(input); validateValue(decoded); return decoded; }; -const decodeCommand = (input: unknown): Command => - (typeof input === "string" ? JSON.parse(input) : input) as Command; +const decodeCommand = (input: unknown): Command => decodeJsonColumn(input); interface MetaSqlRow { readonly collectionId: string; @@ -56,6 +73,11 @@ interface MetaSqlRow { readonly snapshotSeq: number | string; readonly deletedAt: number | string | null; } +const nullableNumber = (value: number | string | null): number | null => { + if (value === null) return null; + return Number(value); +}; + interface SnapshotSqlRow { readonly seq: number | string; readonly schemaVersion: number; @@ -74,8 +96,10 @@ const UNDEFINED_COLUMN = "42703"; /** Postgres SQLSTATE for `insufficient_privilege`. */ const INSUFFICIENT_PRIVILEGE = "42501"; -const sqlErrorCauseProperty = (error: SqlError.SqlError, property: string): unknown => - Predicate.hasProperty(error.reason.cause, property) ? error.reason.cause[property] : undefined; +const sqlErrorCauseProperty = (error: SqlError.SqlError, property: string): unknown => { + if (!Predicate.hasProperty(error.reason.cause, property)) return undefined; + return error.reason.cause[property]; +}; /** Whether a `SqlError` is Postgres's `undefined_table` — the queried table is missing. */ export const isMissingTableError = (error: SqlError.SqlError): boolean => @@ -126,17 +150,16 @@ export const ensureDocumentTables = (config: PgDocumentConfig): Effect.Effect - isDdlDeniedError(createError) - ? Effect.die( - new Error( - `mimic document table "${table}" does not exist and the database denies runtime DDL ` + - `(the connected Postgres role cannot CREATE TABLE). Apply the ` + - `mimic_document_tables migration in packages/db/src/alchemy-migrations before serving traffic.`, - ), - ) - : Effect.fail(createError), - ), + Effect.catch((createError) => { + if (!isDdlDeniedError(createError)) return Effect.fail(createError); + return Effect.die( + new Error( + `mimic document table "${table}" does not exist and the database denies runtime DDL ` + + `(the connected Postgres role cannot CREATE TABLE). Apply the ` + + `mimic_document_tables migration in packages/db/src/alchemy-migrations before serving traffic.`, + ), + ); + }), ); }), ); @@ -162,15 +185,14 @@ export const ensureDocumentTables = (config: PgDocumentConfig): Effect.Effect - isDdlDeniedError(alterError) - ? Effect.die( - new Error( - "mimic_documents.migration_version is missing and the database denies runtime DDL. Apply the current database migrations before serving traffic.", - ), - ) - : Effect.fail(alterError), - ), + Effect.catch((alterError) => { + if (!isDdlDeniedError(alterError)) return Effect.fail(alterError); + return Effect.die( + new Error( + "mimic_documents.migration_version is missing and the database denies runtime DDL. Apply the current database migrations before serving traffic.", + ), + ); + }), ); }), ); @@ -251,7 +273,7 @@ export const makePgDocumentStore = ( migrationVersion: row.migrationVersion, currentSeq: Number(row.currentSeq), snapshotSeq: Number(row.snapshotSeq), - deletedAt: row.deletedAt === null ? null : Number(row.deletedAt), + deletedAt: nullableNumber(row.deletedAt), } satisfies DocumentMeta; }), ), @@ -269,7 +291,7 @@ export const makePgDocumentStore = ( `; yield* sql` INSERT INTO mimic_document_snapshots (document_id, seq, schema_version, state_json) - VALUES (${documentId}, 0, ${schemaVersion}, ${JSON.stringify(value)}::jsonb) + VALUES (${documentId}, 0, ${schemaVersion}, ${encodeJsonColumn(value)}::jsonb) `; }), ), @@ -284,13 +306,12 @@ export const makePgDocumentStore = ( ORDER BY seq DESC LIMIT 1 `; const row = rows[0]; - return row - ? ({ - seq: Number(row.seq), - value: decodeValue(row.stateJson), - schemaVersion: row.schemaVersion, - } satisfies SnapshotRow) - : undefined; + if (!row) return undefined; + return { + seq: Number(row.seq), + value: decodeValue(row.stateJson), + schemaVersion: row.schemaVersion, + } satisfies SnapshotRow; }), ), @@ -323,7 +344,7 @@ export const makePgDocumentStore = ( (command, index) => sql` INSERT INTO mimic_document_commands (document_id, seq, command_json, tx_id) - VALUES (${documentId}, ${fromSeq + 1 + index}, ${JSON.stringify(command)}::jsonb, ${txId}) + VALUES (${documentId}, ${fromSeq + 1 + index}, ${encodeJsonColumn(command)}::jsonb, ${txId}) `, { discard: true }, ); @@ -338,7 +359,7 @@ export const makePgDocumentStore = ( // (e.g. seq 0) with the migrated value + new schema version. yield* sql` INSERT INTO mimic_document_snapshots (document_id, seq, schema_version, state_json) - VALUES (${documentId}, ${seq}, ${schemaVersion}, ${JSON.stringify(value)}::jsonb) + VALUES (${documentId}, ${seq}, ${schemaVersion}, ${encodeJsonColumn(value)}::jsonb) ON CONFLICT (document_id, seq) DO UPDATE SET state_json = EXCLUDED.state_json, schema_version = EXCLUDED.schema_version `; @@ -353,7 +374,7 @@ export const makePgDocumentStore = ( Effect.gen(function* () { yield* sql` INSERT INTO mimic_document_snapshots (document_id, seq, schema_version, state_json) - VALUES (${documentId}, ${seq}, ${schemaVersion}, ${JSON.stringify(value)}::jsonb) + VALUES (${documentId}, ${seq}, ${schemaVersion}, ${encodeJsonColumn(value)}::jsonb) ON CONFLICT (document_id, seq) DO UPDATE SET state_json = EXCLUDED.state_json, schema_version = EXCLUDED.schema_version `; diff --git a/apps/mimic-db/src/document/schema.ts b/apps/mimic-db/src/document/schema.ts index e5fafee68..e515861fe 100644 --- a/apps/mimic-db/src/document/schema.ts +++ b/apps/mimic-db/src/document/schema.ts @@ -1,3 +1,4 @@ +import { causeMessage } from "@voidhash/lib/lang"; import { parseSchema, serializeSchema, @@ -7,27 +8,47 @@ import { type Value, } from "@voidhash/mimic-core"; import { InvalidSchemaError, InvalidValueError } from "@voidhash/mimic-server/rpc"; +import { Effect } from "effect"; -export const normalizeSchemaObject = (input: unknown): SchemaObject => { - try { - return serializeSchema(parseSchema(input)); - } catch (error) { - throw new InvalidSchemaError({ - code: "invalid_schema", - message: error instanceof Error ? error.message : String(error), - }); - } -}; +import { decodeDocumentValue } from "./transaction.ts"; -export const sanitizeValueForSchema = (schemaObject: SchemaObject, input: unknown): Value => { - try { - validateValue(input as Value); - const schema = parseSchema(schemaObject); - return validateSchemaValue(schema, input as Value) as Value; - } catch (error) { - throw new InvalidValueError({ - code: "invalid_value", - message: error instanceof Error ? error.message : String(error), - }); - } -}; +const normalizeSchemaObjectEffect = ( + input: unknown, +): Effect.Effect => + Effect.try({ + try: () => serializeSchema(parseSchema(input)), + catch: (error) => + new InvalidSchemaError({ code: "invalid_schema", message: causeMessage(error) }), + }); + +/** + * Parses and re-serializes a collection schema, failing with `InvalidSchemaError`. + * + * Stays synchronous — the control engine builds schema objects inside plain + * `Effect.try` blocks — so the tagged failure is surfaced by `Effect.runSync`, + * which rethrows the very error the effect failed with. + */ +export const normalizeSchemaObject = (input: unknown): SchemaObject => + Effect.runSync(normalizeSchemaObjectEffect(input)); + +const sanitizeValueForSchemaEffect = ( + schemaObject: SchemaObject, + input: unknown, +): Effect.Effect => + Effect.try({ + try: () => { + const value = decodeDocumentValue(input); + validateValue(value); + const schema = parseSchema(schemaObject); + // `validate` is typed `Value | undefined` for the default-materialization + // path it shares with absent values; a provided value always validates to + // a value. + return decodeDocumentValue(validateSchemaValue(schema, value)); + }, + catch: (error) => + new InvalidValueError({ code: "invalid_value", message: causeMessage(error) }), + }); + +/** Validates a value against a collection schema, failing with `InvalidValueError`. */ +export const sanitizeValueForSchema = (schemaObject: SchemaObject, input: unknown): Value => + Effect.runSync(sanitizeValueForSchemaEffect(schemaObject, input)); diff --git a/apps/mimic-db/src/document/transaction.ts b/apps/mimic-db/src/document/transaction.ts index 7fa58ef40..c3f883687 100644 --- a/apps/mimic-db/src/document/transaction.ts +++ b/apps/mimic-db/src/document/transaction.ts @@ -1,4 +1,4 @@ -import type { Command } from "@voidhash/mimic-core"; +import type { Command, Value } from "@voidhash/mimic-core"; import { Schema } from "effect"; export interface TransactionActor { @@ -21,10 +21,18 @@ export interface SubmitTransactionResponse { readonly reason?: string; } +/** + * Commands cross the wire as opaque JSON. Their shape is dynamic (nine command + * kinds over user-defined paths) and the document engine validates every one as + * it applies it, so decoding here stays lossless and accepts anything — the + * declaration only carries the structured type across the boundary. + */ +const CommandFromWire = Schema.declare((_value): _value is Command => true); + export const TransactionEnvelopeSchema = Schema.Struct({ id: Schema.String, baseVersion: Schema.Number, - commands: Schema.Array(Schema.Unknown), + commands: Schema.Array(CommandFromWire), submittedAt: Schema.optional(Schema.String), actor: Schema.optional( Schema.Struct({ @@ -42,4 +50,15 @@ export const SubmitTransactionResponseSchema = Schema.Struct({ }); export const decodeTransactionEnvelope = (input: unknown): TransactionEnvelope => - Schema.decodeUnknownSync(TransactionEnvelopeSchema)(input) as TransactionEnvelope; + Schema.decodeUnknownSync(TransactionEnvelopeSchema)(input); + +/** + * Same rationale as {@link CommandFromWire} for document and presence values: + * the RPC layer carries them as opaque JSON (`Schema.Unknown`) because their + * shape follows a runtime-defined collection schema, and the host validates + * them against that schema. + */ +const ValueFromWire = Schema.declare((_value): _value is Value => true); + +/** Carries an opaque wire value into the structured `Value` type. */ +export const decodeDocumentValue = Schema.decodeUnknownSync(ValueFromWire); diff --git a/apps/mimic-db/src/entrypoints/standalone/main.ts b/apps/mimic-db/src/entrypoints/standalone/main.ts index 17c1b9a2d..3e66b6b4e 100644 --- a/apps/mimic-db/src/entrypoints/standalone/main.ts +++ b/apps/mimic-db/src/entrypoints/standalone/main.ts @@ -1,7 +1,7 @@ import { createServer } from "node:http"; import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; -import { Layer } from "effect"; +import { Config, Effect, Layer } from "effect"; import { LocalHostServiceDefault } from "../../core/local-host-service.ts"; import { makeHttpApp } from "../../http/rpc-app.ts"; @@ -11,11 +11,14 @@ import { makeHttpApp } from "../../http/rpc-app.ts"; * in-memory `HostService`. Production entry points provide persistent platform * adapters over the same application. */ -const port = Number(process.env.PORT ?? "5001"); - NodeRuntime.runMain( - makeHttpApp(LocalHostServiceDefault).pipe( - Layer.provide(NodeHttpServer.layer(() => createServer(), { port })), - Layer.launch, - ) as never, + Effect.gen(function* () { + const port = yield* Config.number("PORT").pipe(Config.withDefault(5001)); + // `HttpServerRequest` leaks out of the RPC handler layer (handlers read the + // incoming request); the RPC server supplies it per call at runtime. + return yield* (makeHttpApp(LocalHostServiceDefault).pipe( + Layer.provide(NodeHttpServer.layer(() => createServer(), { port })), + Layer.launch, + ) as Effect.Effect); + }), ); diff --git a/apps/mimic-db/src/worker/durable-host-service.ts b/apps/mimic-db/src/worker/durable-host-service.ts index 711106900..046ace5b2 100644 --- a/apps/mimic-db/src/worker/durable-host-service.ts +++ b/apps/mimic-db/src/worker/durable-host-service.ts @@ -70,6 +70,15 @@ export interface DurableHostServiceDeps { const notFound = (message: string): NotFoundError => new NotFoundError({ code: "not_found", message }); +/** Builds the presence entry for a headless connection, omitting an absent `userId`. */ +const presenceEntry = ( + data: Value, + userId: string | undefined, +): { readonly data: Value; readonly userId?: string } => { + if (userId === undefined) return { data }; + return { data, userId }; +}; + const isSubmitResponse = ( value: SubmitTransactionResponse | { notFound: true }, ): value is SubmitTransactionResponse => !("notFound" in value); @@ -161,16 +170,15 @@ export const makeDurableHostService = (deps: DurableHostServiceDeps): HostServic docStub(collectionId, documentId) .getSnapshot() .pipe( - Effect.map((snapshot) => - snapshot.found - ? ({ - id: documentId, - collectionId, - value: snapshot.value, - version: snapshot.version, - } satisfies DocumentSnapshotResponse) - : undefined, - ), + Effect.map((snapshot) => { + if (!snapshot.found) return undefined; + return { + id: documentId, + collectionId, + value: snapshot.value, + version: snapshot.version, + } satisfies DocumentSnapshotResponse; + }), ), ); return snapshots.filter((entry): entry is DocumentSnapshotResponse => entry !== undefined); @@ -209,7 +217,7 @@ export const makeDurableHostService = (deps: DurableHostServiceDeps): HostServic } const snapshot = yield* docStub(collectionId, documentId).openConnection( connectionId, - { data: presence, ...(userId === undefined ? {} : { userId }) }, + presenceEntry(presence, userId), connectionLeaseMs(leaseMs), ); if (!("found" in snapshot)) { @@ -221,34 +229,37 @@ export const makeDurableHostService = (deps: DurableHostServiceDeps): HostServic docStub(collectionId, documentId) .heartbeatConnection(connectionId, connectionLeaseMs(leaseMs)) .pipe( - Effect.flatMap((found) => - found ? Effect.void : Effect.fail(notFound(`Connection not found: ${connectionId}`)), - ), + Effect.flatMap((found) => { + if (found) return Effect.void; + return Effect.fail(notFound(`Connection not found: ${connectionId}`)); + }), ), getConnectionDocument: (collectionId, documentId, connectionId, leaseMs) => docStub(collectionId, documentId) .getConnectionSnapshot(connectionId, connectionLeaseMs(leaseMs)) .pipe( - Effect.flatMap((snapshot) => - "found" in snapshot - ? Effect.succeed({ - id: documentId, - collectionId, - value: snapshot.value, - version: snapshot.version, - }) - : Effect.fail(notFound(`Connection not found: ${connectionId}`)), - ), + Effect.flatMap((snapshot) => { + if (!("found" in snapshot)) { + return Effect.fail(notFound(`Connection not found: ${connectionId}`)); + } + return Effect.succeed({ + id: documentId, + collectionId, + value: snapshot.value, + version: snapshot.version, + }); + }), ), submitConnectionTransaction: (collectionId, documentId, connectionId, transaction, leaseMs) => docStub(collectionId, documentId) .submitConnection(connectionId, connectionLeaseMs(leaseMs), transaction) .pipe( - Effect.flatMap((result) => - "notFound" in result - ? Effect.fail(notFound(`Connection not found: ${connectionId}`)) - : Effect.succeed(result), - ), + Effect.flatMap((result) => { + if ("notFound" in result) { + return Effect.fail(notFound(`Connection not found: ${connectionId}`)); + } + return Effect.succeed(result); + }), ), detachConnection: (collectionId, documentId, connectionId) => docStub(collectionId, documentId).closeConnection(connectionId).pipe(Effect.asVoid), diff --git a/apps/mimic-db/src/ws/document-session.ts b/apps/mimic-db/src/ws/document-session.ts index 62e01f857..a816129f5 100644 --- a/apps/mimic-db/src/ws/document-session.ts +++ b/apps/mimic-db/src/ws/document-session.ts @@ -1,3 +1,4 @@ +import { causeMessage } from "@voidhash/lib/lang"; import type { Value } from "@voidhash/mimic-core"; import { Effect } from "effect"; @@ -229,9 +230,7 @@ export const handleDocumentSocketMessage = ( } } }).pipe( - Effect.catch((error) => - ctx.send(socket, errorMessage(error instanceof Error ? error.message : String(error))), - ), + Effect.catch((error) => ctx.send(socket, errorMessage(causeMessage(error)))), ); /** diff --git a/apps/mimic-db/src/ws/protocol.ts b/apps/mimic-db/src/ws/protocol.ts index ee4497ba9..a04e8d88f 100644 --- a/apps/mimic-db/src/ws/protocol.ts +++ b/apps/mimic-db/src/ws/protocol.ts @@ -1,4 +1,5 @@ -import { Effect } from "effect"; +import { causeMessage } from "@voidhash/lib/lang"; +import { Data, Effect, Schema } from "effect"; import type { Value } from "@voidhash/mimic-core"; import type { PresenceEntry } from "../app/hostService.ts"; @@ -103,15 +104,35 @@ export type ServerMessage = | PresenceRemoveMessage | PresenceSnapshotMessage; +/** A client frame that is not valid JSON. Never leaves the socket handler. */ +export class MalformedClientMessageError extends Data.TaggedError("MalformedClientMessageError")<{ + readonly message: string; +}> {} + +/** + * The wire codec for client frames. Message shape is *not* validated here: + * `handleDocumentSocketMessage` dispatches on `type` and rejects anything it + * does not recognize, so decoding stays lossless for forward-compatible fields. + */ +const ClientMessageFromJson = Schema.fromJsonString( + Schema.declare((_value): _value is ClientMessage => true), +); + +const ServerMessageToJson = Schema.fromJsonString( + Schema.declare((_value): _value is ServerMessage => true), +); + +const decodeText = (data: string | Uint8Array): string => { + if (typeof data === "string") return data; + return new TextDecoder().decode(data); +}; + export const parseClientMessage = ( data: string | Uint8Array, -): Effect.Effect => - Effect.try({ - try: () => { - const text = typeof data === "string" ? data : new TextDecoder().decode(data); - return JSON.parse(text) as ClientMessage; - }, - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); - -export const encodeServerMessage = (message: ServerMessage): string => JSON.stringify(message); +): Effect.Effect => + Schema.decodeUnknownEffect(ClientMessageFromJson)(decodeText(data)).pipe( + Effect.mapError((issue) => new MalformedClientMessageError({ message: causeMessage(issue) })), + ); + +export const encodeServerMessage = (message: ServerMessage): string => + Schema.encodeSync(ServerMessageToJson)(message); diff --git a/apps/mimic-db/src/ws/session-registry.ts b/apps/mimic-db/src/ws/session-registry.ts index 2581e598b..ef93896a4 100644 --- a/apps/mimic-db/src/ws/session-registry.ts +++ b/apps/mimic-db/src/ws/session-registry.ts @@ -1,3 +1,5 @@ +import { Clock, Effect } from "effect"; + /** Timer seam so tests can drive the auth deadline deterministically. */ export interface SessionRegistryTimers { readonly now: () => number; @@ -44,13 +46,23 @@ export interface SessionRegistry { } const defaultTimers: SessionRegistryTimers = { - now: () => Date.now(), + now: () => Effect.runSync(Clock.currentTimeMillis), schedule: (fn, ms) => { - const handle = setTimeout(fn, ms); - return () => clearTimeout(handle); + const fiber = Effect.runFork( + Effect.gen(function* () { + yield* Effect.sleep(ms); + fn(); + }), + ); + return () => fiber.interruptUnsafe(); }, }; +const elapsedSince = (timers: SessionRegistryTimers, connectedAt: number | undefined): number => { + if (connectedAt === undefined) return 0; + return timers.now() - connectedAt; +}; + export const makeSessionRegistry = ( options: SessionRegistryOptions, ): SessionRegistry => { @@ -80,8 +92,7 @@ export const makeSessionRegistry = ( sessions.set(connectionId, socket); return; } - const elapsed = connectedAt === undefined ? 0 : timers.now() - connectedAt; - const remaining = options.authDeadlineMs - elapsed; + const remaining = options.authDeadlineMs - elapsedSince(timers, connectedAt); if (remaining <= 0) { options.close(socket); return; diff --git a/apps/mimic-db/tests/durable-entity-host.test.ts b/apps/mimic-db/tests/durable-entity-host.test.ts index 14d6ffb82..9de8f8be0 100644 --- a/apps/mimic-db/tests/durable-entity-host.test.ts +++ b/apps/mimic-db/tests/durable-entity-host.test.ts @@ -5,89 +5,92 @@ import { describe, expect, test } from "vitest"; import { makeMemoryDurableEntityHost } from "../src/core/local-entity-host.ts"; describe("memory DurableEntity host", () => { - test("serializes operations for the same address in submission order", async () => { - const host = makeMemoryDurableEntityHost(); - const address = makeDurableEntityAddress("document", "one"); - const events: Array = []; - let active = 0; - let overlap = false; + test("serializes operations for the same address in submission order", () => + Effect.runPromise( + Effect.gen(function* () { + const host = makeMemoryDurableEntityHost(); + const address = makeDurableEntityAddress("document", "one"); + const events: Array = []; + let active = 0; + let overlap = false; - const operation = (name: string) => - host.run(address, () => - Effect.gen(function* () { - active += 1; - overlap ||= active > 1; - events.push(`${name}:start`); - yield* Effect.sleep("20 millis"); - events.push(`${name}:end`); - active -= 1; - }), - ); + const operation = (name: string) => + host.run(address, () => + Effect.gen(function* () { + active += 1; + overlap ||= active > 1; + events.push(`${name}:start`); + yield* Effect.sleep("20 millis"); + events.push(`${name}:end`); + active -= 1; + }), + ); - await Effect.runPromise( - Effect.all([operation("first"), operation("second")], { concurrency: "unbounded" }), - ); + yield* Effect.all([operation("first"), operation("second")], { + concurrency: "unbounded", + }); - expect(overlap).toBe(false); - expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]); - }); + expect(overlap).toBe(false); + expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]); + }), + )); - test("allows different entity addresses to run concurrently", async () => { - const host = makeMemoryDurableEntityHost(); - let active = 0; - let maxActive = 0; + test("allows different entity addresses to run concurrently", () => + Effect.runPromise( + Effect.gen(function* () { + const host = makeMemoryDurableEntityHost(); + let active = 0; + let maxActive = 0; - const operation = (id: string) => - host.run(makeDurableEntityAddress("document", id), () => - Effect.gen(function* () { - active += 1; - maxActive = Math.max(maxActive, active); - yield* Effect.sleep("20 millis"); - active -= 1; - }), - ); + const operation = (id: string) => + host.run(makeDurableEntityAddress("document", id), () => + Effect.gen(function* () { + active += 1; + maxActive = Math.max(maxActive, active); + yield* Effect.sleep("20 millis"); + active -= 1; + }), + ); - await Effect.runPromise( - Effect.all([operation("one"), operation("two")], { concurrency: "unbounded" }), - ); + yield* Effect.all([operation("one"), operation("two")], { concurrency: "unbounded" }); - expect(maxActive).toBe(2); - }); + expect(maxActive).toBe(2); + }), + )); - test("retains entity-local key-value, alarm, and session state", async () => { - const host = makeMemoryDurableEntityHost(); - const address = makeDurableEntityAddress("document", "one"); - let attachment: unknown; - const session: DurableEntitySession = { - id: "session-1", - send: () => Effect.void, - close: () => Effect.void, - getAttachment: Effect.sync(() => attachment), - setAttachment: (value) => Effect.sync(() => void (attachment = value)), - }; + test("retains entity-local key-value, alarm, and session state", () => + Effect.runPromise( + Effect.gen(function* () { + const host = makeMemoryDurableEntityHost(); + const address = makeDurableEntityAddress("document", "one"); + let attachment: unknown; + const session: DurableEntitySession = { + id: "session-1", + send: () => Effect.void, + close: () => Effect.void, + getAttachment: Effect.sync(() => attachment), + setAttachment: (value) => Effect.sync(() => void (attachment = value)), + }; - await Effect.runPromise( - host.run(address, (entity) => - Effect.gen(function* () { - yield* entity.keyValue.put("seq", 7); - yield* entity.alarm.set(1234); - yield* entity.sessions.attach(session); - }), - ), - ); + yield* host.run(address, (entity) => + Effect.gen(function* () { + yield* entity.keyValue.put("seq", 7); + yield* entity.alarm.set(1234); + yield* entity.sessions.attach(session); + }), + ); - const state = await Effect.runPromise( - host.run(address, (entity) => - Effect.all({ - seq: entity.keyValue.get("seq"), - alarm: entity.alarm.get, - sessions: entity.sessions.list, - }), - ), - ); + const state = yield* host.run(address, (entity) => + Effect.all({ + seq: entity.keyValue.get("seq"), + alarm: entity.alarm.get, + sessions: entity.sessions.list, + }), + ); - expect(state.seq).toBe(7); - expect(state.alarm).toBe(1234); - expect(state.sessions.map(({ id }) => id)).toEqual(["session-1"]); - }); + expect(state.seq).toBe(7); + expect(state.alarm).toBe(1234); + expect(state.sessions.map(({ id }) => id)).toEqual(["session-1"]); + }), + )); }); diff --git a/apps/mimic-db/tests/helpers.ts b/apps/mimic-db/tests/helpers.ts index c394e87f1..f1492fcef 100644 --- a/apps/mimic-db/tests/helpers.ts +++ b/apps/mimic-db/tests/helpers.ts @@ -1,8 +1,14 @@ import { Effect } from "effect"; -import type { Value } from "@voidhash/mimic-core"; +import type { + NumberValue, + ObjectSchema, + ObjectValue, + StringValue, + Value, +} from "@voidhash/mimic-core"; import type { MigrationRegistry } from "@voidhash/mimic-server/migrate"; -import type { HostService } from "../src/app/hostService.ts"; +import type { HostService, HostServiceTag } from "../src/app/hostService.ts"; import { getConfig } from "../src/config.ts"; import { makeControlEngine, type ControlEngineApi } from "../src/core/control-engine.ts"; import { @@ -19,8 +25,8 @@ import { EmptyMigrationRegistry, ensureMigrationRegistry } from "../src/core/mig * backend. Compose the whole flow into a single program so control + document * state persists across the steps. */ -export const runHost = (program: Effect.Effect): Promise => - Effect.runPromise(program.pipe(Effect.provide(LocalHostServiceDefault)) as Effect.Effect); +export const runHost = (program: Effect.Effect): Promise => + Effect.runPromise(program.pipe(Effect.provide(LocalHostServiceDefault))); /** * Run `program` against a fresh in-memory host, giving it BOTH the host service @@ -33,7 +39,7 @@ export const runHostWithControl = ( program: (deps: { readonly host: HostService; readonly control: ControlEngineApi; - }) => Effect.Effect, + }) => Effect.Effect, ): Promise => Effect.runPromise( Effect.gen(function* () { @@ -49,13 +55,13 @@ export const runHostWithControl = ( config, }); return yield* program({ control, host }); - }).pipe(Effect.provide(MemoryDocumentStoreFactoryLive)) as Effect.Effect, + }).pipe(Effect.provide(MemoryDocumentStoreFactoryLive)), ); /** Runs a program against an in-memory host configured with deployed migrations. */ export const runHostWithRegistry = ( migrations: MigrationRegistry, - program: (host: HostService) => Effect.Effect, + program: (host: HostService) => Effect.Effect, ): Promise => Effect.runPromise( Effect.gen(function* () { @@ -73,27 +79,27 @@ export const runHostWithRegistry = ( config, }); return yield* program(host); - }).pipe(Effect.provide(MemoryDocumentStoreFactoryLive)) as Effect.Effect, + }).pipe(Effect.provide(MemoryDocumentStoreFactoryLive)), ); /** A minimal mimic-core object schema with a single string field. */ -export const titleSchema = { - kind: "object" as const, - fields: { title: { kind: "string" as const, default: { kind: "string" as const, value: "" } } }, +export const titleSchema: ObjectSchema = { + kind: "object", + fields: { title: { kind: "string", default: { kind: "string", value: "" } } }, }; /** v2 of {@link titleSchema}: adds a defaulted `count` number field. */ -export const titleCountSchema = { - kind: "object" as const, +export const titleCountSchema: ObjectSchema = { + kind: "object", fields: { - title: { kind: "string" as const, default: { kind: "string" as const, value: "" } }, - count: { kind: "number" as const, default: { kind: "number" as const, value: 0 } }, + title: { kind: "string", default: { kind: "string", value: "" } }, + count: { kind: "number", default: { kind: "number", value: 0 } }, }, }; -export const objectValue = (fields: Record) => ({ - kind: "object" as const, +export const objectValue = (fields: Record): ObjectValue => ({ + kind: "object", fields, }); -export const stringValue = (value: string) => ({ kind: "string" as const, value }); -export const numberValue = (value: number) => ({ kind: "number" as const, value }); +export const stringValue = (value: string): StringValue => ({ kind: "string", value }); +export const numberValue = (value: number): NumberValue => ({ kind: "number", value }); diff --git a/apps/mimic-db/tests/integration/host-flow.test.ts b/apps/mimic-db/tests/integration/host-flow.test.ts index 5168f9e77..9f5cf941e 100644 --- a/apps/mimic-db/tests/integration/host-flow.test.ts +++ b/apps/mimic-db/tests/integration/host-flow.test.ts @@ -1,3 +1,4 @@ +import type { Value } from "@voidhash/mimic-core"; import { Effect, Result } from "effect"; import { describe, expect, it } from "vitest"; @@ -5,6 +6,14 @@ import { HostServiceTag } from "../../src/app/hostService.ts"; import type { TransactionEnvelope } from "../../src/document/transaction.ts"; import { objectValue, runHost, runHostWithControl, stringValue, titleSchema } from "../helpers.ts"; +/** Reads the `title` string field out of a document value. */ +const titleOf = (value: Value): string | undefined => { + if (value.kind !== "object") return undefined; + const title = value.fields["title"]; + if (title?.kind !== "string") return undefined; + return title.value; +}; + describe("mimic-db host flow (durable-entity engine, in-memory)", () => { it("bootstraps the root user and authenticates", () => runHost( @@ -31,10 +40,10 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { ); expect(created.id).toBe("doc-1"); expect(created.version).toBe(1); - expect((created.value as any).fields.title.value).toBe("Hello"); + expect(titleOf(created.value)).toBe("Hello"); const fetched = yield* host.getDocument(collection.id, "doc-1"); - expect((fetched.value as any).fields.title.value).toBe("Hello"); + expect(titleOf(fetched.value)).toBe("Hello"); expect(fetched.version).toBe(1); }), )); @@ -68,7 +77,7 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { expect(created.id).toBe("doc-1"); const fetched = yield* host.getDocument(recreated.id, "doc-1"); - expect((fetched.value as any).fields.title.value).toBe("Fresh"); + expect(titleOf(fetched.value)).toBe("Fresh"); }), )); @@ -98,7 +107,7 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { expect(created.id).toBe("doc-1"); const fetched = yield* host.getDocument(collection.id, "doc-1"); - expect((fetched.value as any).fields.title.value).toBe("Fresh"); + expect(titleOf(fetched.value)).toBe("Fresh"); }), )); @@ -143,7 +152,7 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { id: "tx-1", baseVersion: 1, commands: [ - { kind: "object.set", path: [], key: "title", value: stringValue("Updated") } as any, + { kind: "object.set", path: [], key: "title", value: stringValue("Updated") }, ], }; const result = yield* host.submitTransaction(collection.id, "doc-1", tx); @@ -151,7 +160,7 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { expect(result.version).toBe(2); const fetched = yield* host.getDocument(collection.id, "doc-1"); - expect((fetched.value as any).fields.title.value).toBe("Updated"); + expect(titleOf(fetched.value)).toBe("Updated"); expect(fetched.version).toBe(2); }), )); @@ -183,13 +192,13 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { id: "tx-connected", baseVersion: 1, commands: [ - { kind: "object.set", path: [], key: "title", value: stringValue("Updated") } as any, + { kind: "object.set", path: [], key: "title", value: stringValue("Updated") }, ], }); expect(result).toMatchObject({ accepted: true, version: 2 }); const connected = yield* host.getConnectionDocument(collection.id, "doc-1", "edit-1"); - expect((connected.value as any).fields.title.value).toBe("Updated"); + expect(titleOf(connected.value)).toBe("Updated"); yield* host.detachConnection(collection.id, "doc-1", "edit-1"); expect((yield* host.getPresenceSnapshot(collection.id, "doc-1")).presences).toEqual({}); @@ -216,7 +225,7 @@ describe("mimic-db host flow (durable-entity engine, in-memory)", () => { id: "tx-stale", baseVersion: 99, commands: [ - { kind: "object.set", path: [], key: "title", value: stringValue("Nope") } as any, + { kind: "object.set", path: [], key: "title", value: stringValue("Nope") }, ], }; const result = yield* host.submitTransaction(collection.id, "doc-1", tx); diff --git a/apps/mimic-db/tests/unit/direct-migration.test.ts b/apps/mimic-db/tests/unit/direct-migration.test.ts index 2af9647cc..33e8d5418 100644 --- a/apps/mimic-db/tests/unit/direct-migration.test.ts +++ b/apps/mimic-db/tests/unit/direct-migration.test.ts @@ -5,7 +5,7 @@ import { type AnyDirectMigration, } from "@voidhash/mimic-server/migrate"; import { makeDurableEntityAddress } from "@voidhash/platform/DurableEntity"; -import { Effect } from "effect"; +import { Cause, Effect } from "effect"; import { describe, expect, it } from "vitest"; import { makeDocumentEngine } from "../../src/core/document-engine.ts"; @@ -61,158 +61,175 @@ const schema = { }; describe("document direct migrations", () => { - it("commits a pending migration once before returning the document", async () => { - const store = makeMemoryDocumentStore(); - let commits = 0; - const trackedStore = { - ...store, - commitMigration: (...args: Parameters) => { - commits += 1; - return store.commitMigration(...args); - }, - }; - const engine = makeDocumentEngine({ - store: trackedStore, - migrations: registryWith(addCount), - schema, - snapshotEveryCommands: 100, - }); - - await Effect.runPromise( - engine.create("collection-1", Original.encode({ title: "Hello" }), 1, 0), - ); - const first = await Effect.runPromise(engine.load()); - const second = await Effect.runPromise(engine.load()); - - expect(Current.decode(first.value)).toEqual({ title: "Hello", count: 7 }); - expect(Current.decode(second.value)).toEqual({ title: "Hello", count: 7 }); - expect(first.migrationVersion).toBe(1); - expect(commits).toBe(1); - }); - - it("leaves persistence unchanged when migration code fails", async () => { - const store = makeMemoryDocumentStore(); - const failing = registryWith(() => - defineMigration({ - version: 1, - name: "fail", - from: Original, - to: Current, - migrate: () => { - throw new Error("boom"); - }, + it("commits a pending migration once before returning the document", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryDocumentStore(); + let commits = 0; + const trackedStore = { + ...store, + commitMigration: (...args: Parameters) => { + commits += 1; + return store.commitMigration(...args); + }, + }; + const engine = makeDocumentEngine({ + store: trackedStore, + migrations: registryWith(addCount), + schema, + snapshotEveryCommands: 100, + }); + + yield* engine.create("collection-1", Original.encode({ title: "Hello" }), 1, 0); + const first = yield* engine.load(); + const second = yield* engine.load(); + + expect(Current.decode(first.value)).toEqual({ title: "Hello", count: 7 }); + expect(Current.decode(second.value)).toEqual({ title: "Hello", count: 7 }); + expect(first.migrationVersion).toBe(1); + expect(commits).toBe(1); }), - ); - const failingEngine = makeDocumentEngine({ - store, - migrations: failing, - schema, - snapshotEveryCommands: 100, - }); - await Effect.runPromise( - failingEngine.create("collection-1", Original.encode({ title: "Hello" }), 1, 0), - ); - - await expect(Effect.runPromise(failingEngine.load())).rejects.toThrow("boom"); - - const fixedEngine = makeDocumentEngine({ - store, - migrations: registryWith(addCount), - schema, - snapshotEveryCommands: 100, - }); - const loaded = await Effect.runPromise(fixedEngine.load()); - expect(Current.decode(loaded.value)).toEqual({ title: "Hello", count: 7 }); - }); - - it("serializes concurrent opens so a migration commits once", async () => { - const store = makeMemoryDocumentStore(); - let commits = 0; - const engine = makeDocumentEngine({ - store: { - ...store, - commitMigration: (...args: Parameters) => { - commits += 1; - return store.commitMigration(...args); - }, - }, - migrations: registryWith(addCount), - schema, - snapshotEveryCommands: 100, - }); - await Effect.runPromise( - engine.create("collection-1", Original.encode({ title: "Hello" }), 1, 0), - ); - - const entities = makeMemoryDurableEntityHost(); - const address = makeDurableEntityAddress("mimic-document", "doc-1"); - const [first, second] = await Promise.all([ - Effect.runPromise(entities.run(address, engine.load)), - Effect.runPromise(entities.run(address, engine.load)), - ]); - - expect(Current.decode(first.value)).toEqual({ title: "Hello", count: 7 }); - expect(Current.decode(second.value)).toEqual({ title: "Hello", count: 7 }); - expect(commits).toBe(1); - }); - - it("rejects legacy executable source without changing persistence", async () => { - const store = makeMemoryDocumentStore(); - const engine = makeDocumentEngine({ - store, - migrations: EmptyMigrationRegistry, - schema: { - getCollectionContext: () => - Effect.succeed({ - collectionId: "collection-1", - databaseName: "example", - collectionName: "documents", - schemaJson: serializeSchema(Current.schema), - schemaVersion: 2, - versions: [ - { - collectionId: "collection-1", - version: 1, - schemaJson: serializeSchema(Original.schema), - dataMigrationSource: null, - }, - { + )); + + it("leaves persistence unchanged when migration code fails", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryDocumentStore(); + const failing = registryWith(() => + defineMigration({ + version: 1, + name: "fail", + from: Original, + to: Current, + // `migrate` is a synchronous callback, so the simulated failure is + // raised as a defect through `Effect.die` instead of a `throw`. + migrate: () => Effect.runSync(Effect.die(new Error("boom"))), + }), + ); + const failingEngine = makeDocumentEngine({ + store, + migrations: failing, + schema, + snapshotEveryCommands: 100, + }); + yield* failingEngine.create("collection-1", Original.encode({ title: "Hello" }), 1, 0); + + const failure = yield* failingEngine.load().pipe( + Effect.as("loaded"), + Effect.catchCause((cause) => Effect.succeed(Cause.pretty(cause))), + ); + expect(failure).toContain("boom"); + + const fixedEngine = makeDocumentEngine({ + store, + migrations: registryWith(addCount), + schema, + snapshotEveryCommands: 100, + }); + const loaded = yield* fixedEngine.load(); + expect(Current.decode(loaded.value)).toEqual({ title: "Hello", count: 7 }); + }), + )); + + it("serializes concurrent opens so a migration commits once", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryDocumentStore(); + let commits = 0; + const engine = makeDocumentEngine({ + store: { + ...store, + commitMigration: (...args: Parameters) => { + commits += 1; + return store.commitMigration(...args); + }, + }, + migrations: registryWith(addCount), + schema, + snapshotEveryCommands: 100, + }); + yield* engine.create("collection-1", Original.encode({ title: "Hello" }), 1, 0); + + const entities = makeMemoryDurableEntityHost(); + const address = makeDurableEntityAddress("mimic-document", "doc-1"); + const [first, second] = yield* Effect.all( + [entities.run(address, engine.load), entities.run(address, engine.load)], + { concurrency: "unbounded" }, + ); + + expect(Current.decode(first.value)).toEqual({ title: "Hello", count: 7 }); + expect(Current.decode(second.value)).toEqual({ title: "Hello", count: 7 }); + expect(commits).toBe(1); + }), + )); + + it("rejects legacy executable source without changing persistence", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryDocumentStore(); + const engine = makeDocumentEngine({ + store, + migrations: EmptyMigrationRegistry, + schema: { + getCollectionContext: () => + Effect.succeed({ collectionId: "collection-1", - version: 2, + databaseName: "example", + collectionName: "documents", schemaJson: serializeSchema(Current.schema), - dataMigrationSource: "return value", - }, - ], - }), - }, - snapshotEveryCommands: 100, - }); - const original = Original.encode({ title: "Hello" }); - await Effect.runPromise(engine.create("collection-1", original, 1, null)); - - await expect(Effect.runPromise(engine.load())).rejects.toThrow( - "executable source, which is no longer supported", - ); - - const meta = await Effect.runPromise(store.readMeta()); - const snapshot = await Effect.runPromise(store.loadLatestSnapshot()); - expect(meta?.schemaVersion).toBe(1); - expect(meta?.migrationVersion).toBeNull(); - expect(snapshot?.value).toEqual(original); - }); - - it("rejects documents newer than the deployed migration registry", async () => { - const store = makeMemoryDocumentStore(); - const engine = makeDocumentEngine({ - store, - migrations: registryWith(addCount), - schema, - snapshotEveryCommands: 100, - }); - await Effect.runPromise( - engine.create("collection-1", Current.encode({ title: "Hello", count: 7 }), 1, 2), - ); - - await expect(Effect.runPromise(engine.load())).rejects.toThrow("newer than deployed version"); - }); + schemaVersion: 2, + versions: [ + { + collectionId: "collection-1", + version: 1, + schemaJson: serializeSchema(Original.schema), + dataMigrationSource: null, + }, + { + collectionId: "collection-1", + version: 2, + schemaJson: serializeSchema(Current.schema), + dataMigrationSource: "return value", + }, + ], + }), + }, + snapshotEveryCommands: 100, + }); + const original = Original.encode({ title: "Hello" }); + yield* engine.create("collection-1", original, 1, null); + + const failure = yield* engine.load().pipe( + Effect.as("loaded"), + Effect.catchCause((cause) => Effect.succeed(Cause.pretty(cause))), + ); + expect(failure).toContain("executable source, which is no longer supported"); + + const meta = yield* store.readMeta(); + const snapshot = yield* store.loadLatestSnapshot(); + expect(meta?.schemaVersion).toBe(1); + expect(meta?.migrationVersion).toBeNull(); + expect(snapshot?.value).toEqual(original); + }), + )); + + it("rejects documents newer than the deployed migration registry", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryDocumentStore(); + const engine = makeDocumentEngine({ + store, + migrations: registryWith(addCount), + schema, + snapshotEveryCommands: 100, + }); + yield* engine.create("collection-1", Current.encode({ title: "Hello", count: 7 }), 1, 2); + + const failure = yield* engine.load().pipe( + Effect.as("loaded"), + Effect.catchCause((cause) => Effect.succeed(Cause.pretty(cause))), + ); + expect(failure).toContain("newer than deployed version"); + }), + )); }); diff --git a/apps/mimic-db/tests/unit/document-auth.test.ts b/apps/mimic-db/tests/unit/document-auth.test.ts index 3974d04c6..c785cd042 100644 --- a/apps/mimic-db/tests/unit/document-auth.test.ts +++ b/apps/mimic-db/tests/unit/document-auth.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildDocumentConnectionUrl } from "../../src/api/handlers/document-auth.ts"; import { getConfig } from "../../src/config.ts"; @@ -84,25 +84,19 @@ describe("buildDocumentConnectionUrl", () => { }); describe("publicBaseUrl config", () => { - const original = process.env.MIMIC_PUBLIC_BASE_URL; - - afterEach(() => { - if (original === undefined) { - delete process.env.MIMIC_PUBLIC_BASE_URL; - } else { - process.env.MIMIC_PUBLIC_BASE_URL = original; - } - }); - + // `vi.stubEnv` + `vi.unstubAllEnvs` replaces the save/restore `afterEach`: + // each test restores the environment it stubbed before it returns. it("reads MIMIC_PUBLIC_BASE_URL when set", () => { - process.env.MIMIC_PUBLIC_BASE_URL = "https://mimic-db.example.workers.dev"; + vi.stubEnv("MIMIC_PUBLIC_BASE_URL", "https://mimic-db.example.workers.dev"); expect(getConfig().publicBaseUrl).toBe("https://mimic-db.example.workers.dev"); + vi.unstubAllEnvs(); }); it("treats unset and blank values as undefined", () => { - delete process.env.MIMIC_PUBLIC_BASE_URL; + vi.stubEnv("MIMIC_PUBLIC_BASE_URL", undefined); expect(getConfig().publicBaseUrl).toBeUndefined(); - process.env.MIMIC_PUBLIC_BASE_URL = " "; + vi.stubEnv("MIMIC_PUBLIC_BASE_URL", " "); expect(getConfig().publicBaseUrl).toBeUndefined(); + vi.unstubAllEnvs(); }); }); diff --git a/apps/mimic-db/tests/unit/document-session.test.ts b/apps/mimic-db/tests/unit/document-session.test.ts index 0610e11dc..4e4d9eae3 100644 --- a/apps/mimic-db/tests/unit/document-session.test.ts +++ b/apps/mimic-db/tests/unit/document-session.test.ts @@ -1,5 +1,5 @@ import { objectValue, stringValue } from "@voidhash/mimic-core"; -import { Effect } from "effect"; +import { Data, Effect, Schema } from "effect"; import { describe, expect, it } from "vitest"; import type { PresenceEntry } from "../../src/app/hostService.ts"; @@ -8,6 +8,7 @@ import { handleDocumentSocketClose, handleDocumentSocketMessage, isolateSessionHook, + type DocumentSessionAuth, type DocumentSessionContext, type SessionAttachment, } from "../../src/ws/document-session.ts"; @@ -22,6 +23,16 @@ interface FakeSocket { const docValue = objectValue({ title: stringValue("Hello") }); +/** Renders a client frame as the JSON text the socket handler receives. */ +const encodeFrame = Schema.encodeSync(Schema.fromJsonString(Schema.Any)); + +/** Rejection raised by the harness for an unrecognised document token. */ +class InvalidTokenError extends Data.TaggedError("InvalidTokenError")<{ + readonly message: string; +}> {} + +const goodTokenAuth: DocumentSessionAuth = { tokenId: "tok-1", permission: "write" }; + const makeManualTimers = () => { let currentNow = 0; const scheduled: Array<{ at: number; fn: () => void; cancelled: boolean }> = []; @@ -82,10 +93,12 @@ const makeHarness = (options?: { Effect.sync(() => { socket.closed = { code, reason }; }), - authenticate: (token) => - token === "good-token" - ? Effect.succeed({ tokenId: "tok-1", permission: "write" as const }) - : Effect.fail(new Error("invalid token")), + authenticate: (token) => { + if (token !== "good-token") { + return Effect.fail(new InvalidTokenError({ message: "invalid token" })); + } + return Effect.succeed(goodTokenAuth); + }, loadDocument: options?.loadDocument ?? (() => Effect.succeed({ value: docValue, version: 1 })), submitTransaction: (envelope) => Effect.succeed({ accepted: true, version: 2, transactionId: envelope.id }), @@ -117,15 +130,16 @@ const makeHarness = (options?: { return socket; }; - const message = (socket: FakeSocket, frame: unknown): Promise => - Effect.runPromise(handleDocumentSocketMessage(ctx, socket, JSON.stringify(frame))); + const message = (socket: FakeSocket, frame: unknown): Effect.Effect => + handleDocumentSocketMessage(ctx, socket, encodeFrame(frame)); - const authenticateSocket = async (connectionId: string): Promise => { - const socket = connectSocket(connectionId); - await message(socket, { type: "auth", token: "good-token" }); - socket.sent.length = 0; - return socket; - }; + const authenticateSocket = (connectionId: string): Effect.Effect => + Effect.gen(function* () { + const socket = connectSocket(connectionId); + yield* message(socket, { type: "auth", token: "good-token" }); + socket.sent.length = 0; + return socket; + }); return { ctx, @@ -141,98 +155,113 @@ const makeHarness = (options?: { }; describe("document session protocol", () => { - it("answers a successful auth with auth_result, snapshot, and presence snapshot", async () => { - const harness = makeHarness(); - const socket = harness.connectSocket("conn-1"); - - await harness.message(socket, { type: "auth", token: "good-token" }); - - expect(socket.sent).toEqual([ - { type: "auth_result", success: true, tokenId: "tok-1", permission: "write" }, - { type: "snapshot", value: docValue, version: 1 }, - { type: "presence_snapshot", selfId: "conn-1", presences: {} }, - ]); - expect(socket.attachment?.authenticated).toBe(true); - expect(harness.registry.authenticated()).toEqual([socket]); - }); - - it("rejects an invalid token without granting the session", async () => { - const harness = makeHarness(); - const socket = harness.connectSocket("conn-1"); - - await harness.message(socket, { type: "auth", token: "wrong" }); - - expect(socket.sent).toEqual([ - { type: "auth_result", success: false, error: "Invalid document token" }, - ]); - expect(socket.attachment?.authenticated).toBe(false); - expect(harness.registry.authenticated()).toEqual([]); - }); - - it("never broadcasts to sockets that have not authenticated", async () => { - const harness = makeHarness(); - const writer = await harness.authenticateSocket("writer"); - const peer = await harness.authenticateSocket("peer"); - const lurker = harness.connectSocket("lurker"); - - await harness.message(writer, { - type: "submit", - transaction: { id: "tx-1", baseVersion: 1, commands: [] }, - }); - await harness.message(writer, { - type: "presence_set", - data: objectValue({ name: stringValue("w") }), - }); - - expect(lurker.sent).toEqual([]); - expect(peer.sent.map((m) => m.type)).toEqual(["transaction", "presence_update"]); - expect(writer.sent.map((m) => m.type)).toEqual(["transaction", "presence_update"]); - }); - - it("sends an error frame and closes the socket when the snapshot load fails after auth", async () => { - const harness = makeHarness({ - loadDocument: () => Effect.fail({ message: "database unreachable" }), - }); - const socket = harness.connectSocket("conn-1"); - - await harness.message(socket, { type: "auth", token: "good-token" }); - - expect(socket.sent).toEqual([ - { - type: "error", - transactionId: undefined, - reason: "Failed to load document: database unreachable", - }, - ]); - expect(socket.closed).toEqual({ code: 1011, reason: "Document load failed" }); - expect(harness.registry.authenticated()).toEqual([]); - }); - - it("broadcasts presence_remove to peers when a socket with presence closes", async () => { - const harness = makeHarness(); - const leaver = await harness.authenticateSocket("leaver"); - const peer = await harness.authenticateSocket("peer"); - - await harness.message(leaver, { - type: "presence_set", - data: objectValue({ name: stringValue("l") }), - }); - peer.sent.length = 0; + it("answers a successful auth with auth_result, snapshot, and presence snapshot", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const socket = harness.connectSocket("conn-1"); + + yield* harness.message(socket, { type: "auth", token: "good-token" }); + + expect(socket.sent).toEqual([ + { type: "auth_result", success: true, tokenId: "tok-1", permission: "write" }, + { type: "snapshot", value: docValue, version: 1 }, + { type: "presence_snapshot", selfId: "conn-1", presences: {} }, + ]); + expect(socket.attachment?.authenticated).toBe(true); + expect(harness.registry.authenticated()).toEqual([socket]); + }), + )); - await Effect.runPromise(handleDocumentSocketClose(harness.ctx, leaver)); + it("rejects an invalid token without granting the session", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const socket = harness.connectSocket("conn-1"); - expect(peer.sent).toEqual([{ type: "presence_remove", id: "leaver" }]); - expect(harness.presence.has("leaver")).toBe(false); - expect(harness.registry.authenticated()).toEqual([peer]); + yield* harness.message(socket, { type: "auth", token: "wrong" }); - // Closing a socket without presence broadcasts nothing. - peer.sent.length = 0; - const quiet = await harness.authenticateSocket("quiet"); - await Effect.runPromise(handleDocumentSocketClose(harness.ctx, quiet)); - expect(peer.sent).toEqual([]); - }); + expect(socket.sent).toEqual([ + { type: "auth_result", success: false, error: "Invalid document token" }, + ]); + expect(socket.attachment?.authenticated).toBe(false); + expect(harness.registry.authenticated()).toEqual([]); + }), + )); + + it("never broadcasts to sockets that have not authenticated", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const writer = yield* harness.authenticateSocket("writer"); + const peer = yield* harness.authenticateSocket("peer"); + const lurker = harness.connectSocket("lurker"); + + yield* harness.message(writer, { + type: "submit", + transaction: { id: "tx-1", baseVersion: 1, commands: [] }, + }); + yield* harness.message(writer, { + type: "presence_set", + data: objectValue({ name: stringValue("w") }), + }); + + expect(lurker.sent).toEqual([]); + expect(peer.sent.map((m) => m.type)).toEqual(["transaction", "presence_update"]); + expect(writer.sent.map((m) => m.type)).toEqual(["transaction", "presence_update"]); + }), + )); + + it("sends an error frame and closes the socket when the snapshot load fails after auth", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness({ + loadDocument: () => Effect.fail({ message: "database unreachable" }), + }); + const socket = harness.connectSocket("conn-1"); + + yield* harness.message(socket, { type: "auth", token: "good-token" }); + + expect(socket.sent).toEqual([ + { + type: "error", + transactionId: undefined, + reason: "Failed to load document: database unreachable", + }, + ]); + expect(socket.closed).toEqual({ code: 1011, reason: "Document load failed" }); + expect(harness.registry.authenticated()).toEqual([]); + }), + )); + + it("broadcasts presence_remove to peers when a socket with presence closes", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const leaver = yield* harness.authenticateSocket("leaver"); + const peer = yield* harness.authenticateSocket("peer"); + + yield* harness.message(leaver, { + type: "presence_set", + data: objectValue({ name: stringValue("l") }), + }); + peer.sent.length = 0; + + yield* handleDocumentSocketClose(harness.ctx, leaver); + + expect(peer.sent).toEqual([{ type: "presence_remove", id: "leaver" }]); + expect(harness.presence.has("leaver")).toBe(false); + expect(harness.registry.authenticated()).toEqual([peer]); + + // Closing a socket without presence broadcasts nothing. + peer.sent.length = 0; + const quiet = yield* harness.authenticateSocket("quiet"); + yield* handleDocumentSocketClose(harness.ctx, quiet); + expect(peer.sent).toEqual([]); + }), + )); - it("closes sockets that never authenticate once the deadline passes", async () => { + it("closes sockets that never authenticate once the deadline passes", () => { const harness = makeHarness(); const socket = harness.connectSocket("conn-1"); @@ -242,88 +271,105 @@ describe("document session protocol", () => { expect(socket.closed).toEqual({ code: 1008, reason: "Authentication deadline exceeded" }); }); - it("keeps authenticated sockets alive past the auth deadline", async () => { - const harness = makeHarness(); - const socket = await harness.authenticateSocket("conn-1"); - - harness.advance(AUTH_DEADLINE_MS * 10); - expect(socket.closed).toBeNull(); - expect(harness.registry.authenticated()).toEqual([socket]); - }); + it("keeps authenticated sockets alive past the auth deadline", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const socket = yield* harness.authenticateSocket("conn-1"); - it("reports the accepted sequence to the idle-notify host on submit", async () => { - const harness = makeHarness(); - const writer = await harness.authenticateSocket("writer"); + harness.advance(AUTH_DEADLINE_MS * 10); + expect(socket.closed).toBeNull(); + expect(harness.registry.authenticated()).toEqual([socket]); + }), + )); - await harness.message(writer, { - type: "submit", - transaction: { id: "tx-1", baseVersion: 1, commands: [] }, - }); + it("reports the accepted sequence to the idle-notify host on submit", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const writer = yield* harness.authenticateSocket("writer"); - // The stub submit returns version 2, so the current sequence is 1. - expect(harness.acceptedSeqs).toEqual([1]); - }); + yield* harness.message(writer, { + type: "submit", + transaction: { id: "tx-1", baseVersion: 1, commands: [] }, + }); - it("signals the idle-notify host only when the last authenticated socket closes", async () => { - const harness = makeHarness(); - const first = await harness.authenticateSocket("first"); - const second = await harness.authenticateSocket("second"); + // The stub submit returns version 2, so the current sequence is 1. + expect(harness.acceptedSeqs).toEqual([1]); + }), + )); - await Effect.runPromise(handleDocumentSocketClose(harness.ctx, first)); - expect(harness.lastAuthenticatedCloses()).toBe(0); + it("signals the idle-notify host only when the last authenticated socket closes", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + const first = yield* harness.authenticateSocket("first"); + const second = yield* harness.authenticateSocket("second"); - await Effect.runPromise(handleDocumentSocketClose(harness.ctx, second)); - expect(harness.lastAuthenticatedCloses()).toBe(1); - }); + yield* handleDocumentSocketClose(harness.ctx, first); + expect(harness.lastAuthenticatedCloses()).toBe(0); - it("completes close cleanup even when an isolated onLastAuthenticatedClose hook dies", async () => { - const harness = makeHarness(); - // The DO wires the storage-backed hook through `isolateSessionHook`; model a - // hook whose underlying storage effect DIES (a defect, not a typed failure). - const dyingCtx: DocumentSessionContext = { - ...harness.ctx, - onLastAuthenticatedClose: () => - isolateSessionHook( - Effect.die(new Error("storage unavailable")), - "onLastAuthenticatedClose", - ), - }; - const leaver = await harness.authenticateSocket("leaver"); - await Effect.runPromise( - handleDocumentSocketMessage( - harness.ctx, - leaver, - JSON.stringify({ type: "presence_set", data: objectValue({ name: stringValue("l") }) }), - ), - ); - - // Must resolve (not reject) — the die is swallowed by the isolation wrapper — - // and the registry/presence cleanup still runs. - await Effect.runPromise(handleDocumentSocketClose(dyingCtx, leaver)); - - expect(harness.presence.has("leaver")).toBe(false); - expect(harness.registry.authenticated()).toEqual([]); - }); + yield* handleDocumentSocketClose(harness.ctx, second); + expect(harness.lastAuthenticatedCloses()).toBe(1); + }), + )); + + it("completes close cleanup even when an isolated onLastAuthenticatedClose hook dies", () => + Effect.runPromise( + Effect.gen(function* () { + const harness = makeHarness(); + // The DO wires the storage-backed hook through `isolateSessionHook`; model a + // hook whose underlying storage effect DIES (a defect, not a typed failure). + const dyingCtx: DocumentSessionContext = { + ...harness.ctx, + onLastAuthenticatedClose: () => + isolateSessionHook( + Effect.die(new Error("storage unavailable")), + "onLastAuthenticatedClose", + ), + }; + const leaver = yield* harness.authenticateSocket("leaver"); + yield* handleDocumentSocketMessage( + harness.ctx, + leaver, + encodeFrame({ type: "presence_set", data: objectValue({ name: stringValue("l") }) }), + ); + + // Must complete (not fail) — the die is swallowed by the isolation wrapper — + // and the registry/presence cleanup still runs. + yield* handleDocumentSocketClose(dyingCtx, leaver); + + expect(harness.presence.has("leaver")).toBe(false); + expect(harness.registry.authenticated()).toEqual([]); + }), + )); }); describe("isolateSessionHook", () => { - it("swallows a die and returns void so the caller proceeds", async () => { - let ran = false; - const result = await Effect.runPromise( - isolateSessionHook(Effect.die(new Error("boom")), "recordDirty").pipe( - Effect.tap(() => - Effect.sync(() => { - ran = true; - }), - ), - ), - ); - expect(ran).toBe(true); - expect(result).toBeUndefined(); - }); + it("swallows a die and returns void so the caller proceeds", () => + Effect.runPromise( + Effect.gen(function* () { + let ran = false; + const result = yield* isolateSessionHook( + Effect.die(new Error("boom")), + "recordDirty", + ).pipe( + Effect.tap(() => + Effect.sync(() => { + ran = true; + }), + ), + ); + expect(ran).toBe(true); + expect(result).toBeUndefined(); + }), + )); - it("passes a succeeding hook through untouched", async () => { - const result = await Effect.runPromise(isolateSessionHook(Effect.succeed(42), "recordDirty")); - expect(result).toBe(42); - }); + it("passes a succeeding hook through untouched", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* isolateSessionHook(Effect.succeed(42), "recordDirty"); + expect(result).toBe(42); + }), + )); }); diff --git a/apps/mimic-db/tests/unit/idle-notify.test.ts b/apps/mimic-db/tests/unit/idle-notify.test.ts index 82c31eb0d..01d82e57f 100644 --- a/apps/mimic-db/tests/unit/idle-notify.test.ts +++ b/apps/mimic-db/tests/unit/idle-notify.test.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Data, Effect } from "effect"; import { describe, expect, it } from "vitest"; import { @@ -6,6 +6,7 @@ import { IDLE_NOTIFIED_SEQ_KEY, makeIdleNotifier, type IdleNotifyStorage, + type MimicDocumentIdleMessageType, } from "../../src/ws/idle-notify.ts"; interface Published { @@ -14,6 +15,11 @@ interface Published { readonly seq: number; } +/** Simulated queue outage used by the publish-failure case. */ +class PublishFailedError extends Data.TaggedError("PublishFailedError")<{ + readonly message: string; +}> {} + const makeHarness = (options?: { readonly authenticatedCount?: () => number; readonly publishFails?: boolean; @@ -33,6 +39,16 @@ const makeHarness = (options?: { }), }; let now = 1_000; + const publish = ( + message: MimicDocumentIdleMessageType, + ): Effect.Effect => { + if (options?.publishFails) { + return Effect.fail(new PublishFailedError({ message: "queue down" })); + } + return Effect.sync(() => { + published.push(message); + }); + }; const notifier = makeIdleNotifier({ collectionId: "col-1", documentId: "doc-1", @@ -40,12 +56,7 @@ const makeHarness = (options?: { debounceMs: 15_000, now: () => now, authenticatedCount: options?.authenticatedCount ?? (() => 0), - publish: (message) => - options?.publishFails - ? Effect.fail(new Error("queue down")) - : Effect.sync(() => { - published.push(message); - }), + publish, }); return { notifier, @@ -59,64 +70,88 @@ const makeHarness = (options?: { }; describe("idle notifier", () => { - it("records the dirty sequence on an accepted transaction", async () => { - const h = makeHarness(); - await Effect.runPromise(h.notifier.recordDirty(7)); - expect(h.store.get(IDLE_DIRTY_SEQ_KEY)).toBe(7); - }); + it("records the dirty sequence on an accepted transaction", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness(); + yield* h.notifier.recordDirty(7); + expect(h.store.get(IDLE_DIRTY_SEQ_KEY)).toBe(7); + }), + )); - it("arms the debounce alarm when the last socket leaves a dirty document", async () => { - const h = makeHarness(); - await Effect.runPromise(h.notifier.recordDirty(3)); - await Effect.runPromise(h.notifier.onLastAuthenticatedClose()); - expect(h.alarms).toEqual([1_000 + 15_000]); - }); + it("arms the debounce alarm when the last socket leaves a dirty document", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness(); + yield* h.notifier.recordDirty(3); + yield* h.notifier.onLastAuthenticatedClose(); + expect(h.alarms).toEqual([1_000 + 15_000]); + }), + )); - it("does not arm an alarm when nothing new has been edited", async () => { - const h = makeHarness(); - h.store.set(IDLE_DIRTY_SEQ_KEY, 5); - h.store.set(IDLE_NOTIFIED_SEQ_KEY, 5); - await Effect.runPromise(h.notifier.onLastAuthenticatedClose()); - expect(h.alarms).toEqual([]); - }); + it("does not arm an alarm when nothing new has been edited", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness(); + h.store.set(IDLE_DIRTY_SEQ_KEY, 5); + h.store.set(IDLE_NOTIFIED_SEQ_KEY, 5); + yield* h.notifier.onLastAuthenticatedClose(); + expect(h.alarms).toEqual([]); + }), + )); - it("does not arm an alarm while authenticated sockets remain", async () => { - const h = makeHarness({ authenticatedCount: () => 1 }); - await Effect.runPromise(h.notifier.recordDirty(9)); - await Effect.runPromise(h.notifier.onLastAuthenticatedClose()); - expect(h.alarms).toEqual([]); - }); + it("does not arm an alarm while authenticated sockets remain", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness({ authenticatedCount: () => 1 }); + yield* h.notifier.recordDirty(9); + yield* h.notifier.onLastAuthenticatedClose(); + expect(h.alarms).toEqual([]); + }), + )); - it("publishes the dirty sequence and records it as notified on alarm", async () => { - const h = makeHarness(); - await Effect.runPromise(h.notifier.recordDirty(4)); - await Effect.runPromise(h.notifier.onAlarm()); - expect(h.published).toEqual([{ collectionId: "col-1", documentId: "doc-1", seq: 4 }]); - expect(h.store.get(IDLE_NOTIFIED_SEQ_KEY)).toBe(4); - }); + it("publishes the dirty sequence and records it as notified on alarm", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness(); + yield* h.notifier.recordDirty(4); + yield* h.notifier.onAlarm(); + expect(h.published).toEqual([{ collectionId: "col-1", documentId: "doc-1", seq: 4 }]); + expect(h.store.get(IDLE_NOTIFIED_SEQ_KEY)).toBe(4); + }), + )); - it("skips publishing on alarm when a socket reconnected", async () => { - const h = makeHarness({ authenticatedCount: () => 1 }); - await Effect.runPromise(h.notifier.recordDirty(4)); - await Effect.runPromise(h.notifier.onAlarm()); - expect(h.published).toEqual([]); - expect(h.store.get(IDLE_NOTIFIED_SEQ_KEY)).toBeUndefined(); - }); + it("skips publishing on alarm when a socket reconnected", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness({ authenticatedCount: () => 1 }); + yield* h.notifier.recordDirty(4); + yield* h.notifier.onAlarm(); + expect(h.published).toEqual([]); + expect(h.store.get(IDLE_NOTIFIED_SEQ_KEY)).toBeUndefined(); + }), + )); - it("does not re-notify an already-notified sequence", async () => { - const h = makeHarness(); - h.store.set(IDLE_DIRTY_SEQ_KEY, 6); - h.store.set(IDLE_NOTIFIED_SEQ_KEY, 6); - await Effect.runPromise(h.notifier.onAlarm()); - expect(h.published).toEqual([]); - }); + it("does not re-notify an already-notified sequence", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness(); + h.store.set(IDLE_DIRTY_SEQ_KEY, 6); + h.store.set(IDLE_NOTIFIED_SEQ_KEY, 6); + yield* h.notifier.onAlarm(); + expect(h.published).toEqual([]); + }), + )); - it("leaves notifiedSeq unpersisted when the publish fails", async () => { - const h = makeHarness({ publishFails: true }); - await Effect.runPromise(h.notifier.recordDirty(8)); - await Effect.runPromise(h.notifier.onAlarm()); - expect(h.store.get(IDLE_NOTIFIED_SEQ_KEY)).toBeUndefined(); - // The dirty seq is untouched, so the next disconnect re-triggers. - expect(h.store.get(IDLE_DIRTY_SEQ_KEY)).toBe(8); - }); + it("leaves notifiedSeq unpersisted when the publish fails", () => + Effect.runPromise( + Effect.gen(function* () { + const h = makeHarness({ publishFails: true }); + yield* h.notifier.recordDirty(8); + yield* h.notifier.onAlarm(); + expect(h.store.get(IDLE_NOTIFIED_SEQ_KEY)).toBeUndefined(); + // The dirty seq is untouched, so the next disconnect re-triggers. + expect(h.store.get(IDLE_DIRTY_SEQ_KEY)).toBe(8); + }), + )); }); diff --git a/apps/mimic-db/tests/unit/migration-registry.test.ts b/apps/mimic-db/tests/unit/migration-registry.test.ts index 202641435..8924a4ce4 100644 --- a/apps/mimic-db/tests/unit/migration-registry.test.ts +++ b/apps/mimic-db/tests/unit/migration-registry.test.ts @@ -1,6 +1,6 @@ import { Primitive, serializeSchema } from "@voidhash/mimic-core"; import { defineMigration, defineMigrationRegistry } from "@voidhash/mimic-server/migrate"; -import { Effect } from "effect"; +import { Cause, Effect } from "effect"; import { describe, expect, it } from "vitest"; import { makeControlEngine } from "../../src/core/control-engine.ts"; @@ -30,24 +30,25 @@ const registry = defineMigrationRegistry([ ]); describe("migration registry provisioning", () => { - it("creates registry resources directly at the latest deployed version", async () => { - const store = makeMemoryControlStore(); - await Effect.runPromise(ensureMigrationRegistry(store, registry)); + it("creates registry resources directly at the latest deployed version", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryControlStore(); + yield* ensureMigrationRegistry(store, registry); - const database = await Effect.runPromise(store.findDatabaseByName("example")); - const collection = await Effect.runPromise( - store.findCollectionByName(database!.id, "documents"), - ); + const database = yield* store.findDatabaseByName("example"); + const collection = yield* store.findCollectionByName(database!.id, "documents"); - expect(collection?.schemaJson).toEqual(serializeSchema(Current.schema)); - expect(collection?.schemaVersion).toBe(1); - expect(collection?.migrationVersion).toBe(1); - }); + expect(collection?.schemaJson).toEqual(serializeSchema(Current.schema)); + expect(collection?.schemaVersion).toBe(1); + expect(collection?.migrationVersion).toBe(1); + }), + )); - it("captures a final source-free baseline for an existing collection", async () => { - const store = makeMemoryControlStore(); - await Effect.runPromise( + it("captures a final source-free baseline for an existing collection", () => + Effect.runPromise( Effect.gen(function* () { + const store = makeMemoryControlStore(); yield* store.createDatabase({ id: "db", name: "example", description: "" }); yield* store.createCollection({ id: "collection", @@ -64,41 +65,37 @@ describe("migration registry provisioning", () => { dataMigrationSource: null, }); yield* ensureMigrationRegistry(store, registry); - }), - ); - const collection = await Effect.runPromise(store.findCollectionById("collection")); - const baseline = await Effect.runPromise(store.findSchemaVersion("collection", 5)); - expect(collection?.schemaVersion).toBe(5); - expect(collection?.migrationVersion).toBe(1); - expect(baseline?.schemaJson).toEqual(serializeSchema(Baseline.schema)); - expect(baseline?.dataMigrationSource).toBeNull(); - }); + const collection = yield* store.findCollectionById("collection"); + const baseline = yield* store.findSchemaVersion("collection", 5); + expect(collection?.schemaVersion).toBe(5); + expect(collection?.migrationVersion).toBe(1); + expect(baseline?.schemaJson).toEqual(serializeSchema(Baseline.schema)); + expect(baseline?.dataMigrationSource).toBeNull(); + }), + )); - it("prevents public deletion of registry-owned resources", async () => { - const store = makeMemoryControlStore(); - await Effect.runPromise(ensureMigrationRegistry(store, registry)); - const database = await Effect.runPromise(store.findDatabaseByName("example")); - const collection = await Effect.runPromise( - store.findCollectionByName(database!.id, "documents"), - ); - const control = makeControlEngine(store, registry); + it("prevents public deletion of registry-owned resources", () => + Effect.runPromise( + Effect.gen(function* () { + const store = makeMemoryControlStore(); + yield* ensureMigrationRegistry(store, registry); + const database = yield* store.findDatabaseByName("example"); + const collection = yield* store.findCollectionByName(database!.id, "documents"); + const control = makeControlEngine(store, registry); - const databaseResult = await Effect.runPromise( - Effect.result(control.deleteDatabase(database!.id)), - ); - const collectionResult = await Effect.runPromise( - Effect.result(control.deleteCollection(collection!.id)), - ); + const databaseResult = yield* Effect.result(control.deleteDatabase(database!.id)); + const collectionResult = yield* Effect.result(control.deleteCollection(collection!.id)); - expect(databaseResult._tag).toBe("Failure"); - expect(collectionResult._tag).toBe("Failure"); - }); + expect(databaseResult._tag).toBe("Failure"); + expect(collectionResult._tag).toBe("Failure"); + }), + )); - it("refuses to bootstrap with an older deployed registry", async () => { - const store = makeMemoryControlStore(); - await Effect.runPromise( + it("refuses to bootstrap with an older deployed registry", () => + Effect.runPromise( Effect.gen(function* () { + const store = makeMemoryControlStore(); yield* store.createDatabase({ id: "db", name: "example", description: "" }); yield* store.createCollection({ id: "collection", @@ -108,11 +105,12 @@ describe("migration registry provisioning", () => { schemaVersion: 1, migrationVersion: 2, }); - }), - ); - await expect(Effect.runPromise(ensureMigrationRegistry(store, registry))).rejects.toThrow( - "newer than deployed version", - ); - }); + const outcome = yield* ensureMigrationRegistry(store, registry).pipe( + Effect.as("provisioned"), + Effect.catchCause((cause) => Effect.succeed(Cause.pretty(cause))), + ); + expect(outcome).toContain("newer than deployed version"); + }), + )); }); diff --git a/apps/mimic-db/tests/unit/pg-store.test.ts b/apps/mimic-db/tests/unit/pg-store.test.ts index aa7fd7cd6..e15874dd6 100644 --- a/apps/mimic-db/tests/unit/pg-store.test.ts +++ b/apps/mimic-db/tests/unit/pg-store.test.ts @@ -14,11 +14,17 @@ const sqlErrorWithCause = (cause: unknown): SqlError.SqlError => }), }); -const pgError = (code: string, message: string): Error => { - const error = new Error(message); - (error as Error & { code: string }).code = code; - return error; -}; +/** A stand-in for the driver error node-postgres raises: an `Error` with a SQLSTATE `code`. */ +class PgDriverError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + } +} + +const pgError = (code: string, message: string): Error => new PgDriverError(code, message); describe("pg-store error classification", () => { it("detects undefined_table (42P01) as a missing table", () => { diff --git a/apps/mimic-db/vitest.mts b/apps/mimic-db/vitest.mts index d8c77b4e5..3a0cf7062 100644 --- a/apps/mimic-db/vitest.mts +++ b/apps/mimic-db/vitest.mts @@ -1,18 +1,16 @@ import { defineConfig } from "vite-plus"; import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; -const rootDir = dirname(fileURLToPath(import.meta.url)); +const mimicCoreEntry = fileURLToPath( + new URL("../../packages/mimic-core/src/index.ts", import.meta.url), +); // Unit tier: every `*.test.ts` except the stack-backed integration files. // `scripts/check-test-tiers.mjs` enforces this split across the repository. export default defineConfig({ resolve: { alias: { - "@voidhash/mimic-core": resolve( - rootDir, - "../../packages/mimic-core/src/index.ts", - ), + "@voidhash/mimic-core": mimicCoreEntry, }, }, test: { diff --git a/apps/studio/package.json b/apps/studio/package.json index 1db409aa1..23bf0d47f 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -23,9 +23,12 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@effect/platform-node": "catalog:", "@tailwindcss/vite": "^4.1.13", "@vitejs/plugin-react": "^5.2.0", + "@voidhash/lib": "workspace:*", "@voidhash/paywalls": "workspace:*", + "effect": "catalog:", "react": "catalog:", "react-dom": "catalog:", "tailwindcss": "^4.1.13", diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index b0b9c4074..28e796f56 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -33,6 +33,26 @@ export const App = (): ReactNode => { setEvents([]); }; + const renderPreview = (): ReactNode => { + if (!selected) { + return ( +
+ Create a paywall in{" "} + .voidhash/paywalls to + preview it here. +
+ ); + } + return ( + + ); + }; + return (
@@ -51,22 +71,7 @@ export const App = (): ReactNode => {
-
- {selected ? ( - - ) : ( -
- Create a paywall in{" "} - .voidhash/paywalls{" "} - to preview it here. -
- )} -
+
{renderPreview()}
(
{children} @@ -26,10 +30,11 @@ const envelopeDetail = (envelope: PaywallOutboundEnvelope): string | null => { return envelope.payload?.source ?? null; case "openExternal": return envelope.payload.url; - case "event": - return envelope.payload.properties - ? `${envelope.payload.name} ${JSON.stringify(envelope.payload.properties)}` - : envelope.payload.name; + case "event": { + const { name, properties } = envelope.payload; + if (!properties) return name; + return `${name} ${encodeJson(properties)}`; + } case "log": return `${envelope.payload.level}: ${envelope.payload.message}`; default: @@ -37,6 +42,62 @@ const envelopeDetail = (envelope: PaywallOutboundEnvelope): string | null => { } }; +/** A component's static preview, or a hint when the file exports no definition. */ +const ComponentBody = ({ + entry, + profile, +}: { + entry: ComponentEntry; + profile: PreviewDeviceProfile; +}): ReactNode => { + if (!entry.definition) { + return ( +

+ No defineComponent(...) export found. +

+ ); + } + return ( +
+ +
+ ); +}; + +/** The live log of bridge envelopes, newest first. */ +const EventLog = ({ events }: { events: ReadonlyArray }): ReactNode => { + if (events.length === 0) { + return ( +

+ Envelopes posted by the paywall (ready, purchase, …) show up here. +

+ ); + } + return ( +
    + {events + .slice() + .reverse() + .map((event) => { + const detail = envelopeDetail(event.envelope); + return ( +
  • + {event.envelope.type} + {detail && ( + + {detail} + + )} +
  • + ); + })} +
+ ); +}; + export interface SidebarProps { paywalls: ReadonlyArray; components: ReadonlyArray; @@ -83,9 +144,8 @@ export const Sidebar = ({
- {c.definition ? ( -
- -
- ) : ( -

- No defineComponent(...) export found. -

- )} +
))}
@@ -162,33 +214,7 @@ export const Sidebar = ({ )}
- {events.length === 0 ? ( -

- Envelopes posted by the paywall (ready, purchase, …) show up here. -

- ) : ( -
    - {events - .slice() - .reverse() - .map((event) => { - const detail = envelopeDetail(event.envelope); - return ( -
  • - {event.envelope.type} - {detail && ( - - {detail} - - )} -
  • - ); - })} -
- )} +
diff --git a/apps/studio/src/main.tsx b/apps/studio/src/main.tsx index a1aa34dea..0c3ec7819 100644 --- a/apps/studio/src/main.tsx +++ b/apps/studio/src/main.tsx @@ -1,14 +1,23 @@ +import { Effect } from "effect"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; import "./index.css"; -const container = document.getElementById("root"); -if (!container) throw new Error("Studio root element #root not found"); +const mount = Effect.gen(function* () { + const container = document.getElementById("root"); + if (!container) { + return yield* Effect.die(new Error("Studio root element #root not found")); + } -createRoot(container).render( - - - , -); + yield* Effect.sync(() => + createRoot(container).render( + + + , + ), + ); +}); + +Effect.runSync(mount); diff --git a/apps/studio/src/server/config.ts b/apps/studio/src/server/config.ts index 5f6bb58d8..25442a257 100644 --- a/apps/studio/src/server/config.ts +++ b/apps/studio/src/server/config.ts @@ -1,4 +1,3 @@ -import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import tailwindcss from "@tailwindcss/vite"; @@ -7,8 +6,12 @@ import type { InlineConfig } from "vite"; import { voidhashPaywallsPlugin } from "./virtual-paywalls-plugin"; -/** Absolute path to the Studio app root (the folder containing `index.html`). */ -export const STUDIO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +/** + * Absolute path to the Studio app root (the folder containing `index.html`). + * Resolved through `URL` rather than `node:path`; the trailing separator a + * directory URL carries is trimmed so the value stays a plain directory path. + */ +export const STUDIO_ROOT = fileURLToPath(new URL("../..", import.meta.url)).replace(/[/\\]$/, ""); export interface StudioViteConfigOptions { /** The user's project root (folder containing `.voidhash`). */ diff --git a/apps/studio/src/server/index.ts b/apps/studio/src/server/index.ts index 067c75096..9a571505b 100644 --- a/apps/studio/src/server/index.ts +++ b/apps/studio/src/server/index.ts @@ -1,3 +1,5 @@ +import { causeMessage } from "@voidhash/lib/lang"; +import { Data, Effect } from "effect"; import { createServer, type ViteDevServer } from "vite"; import { createStudioViteConfig } from "./config"; @@ -24,25 +26,37 @@ export interface StudioHandle { const DEFAULT_PORT = 4830; +/** Raised when the Studio Vite dev server cannot be created or bound. */ +export class StudioStartError extends Data.TaggedError("StudioStartError")<{ + readonly message: string; +}> {} + /** * Boots the Studio Vite dev server for a given project and returns a handle. * This is the programmatic entry point the CLI's `studio` command calls. */ -export const startStudio = async ({ +export const startStudio = ({ projectRoot, port = DEFAULT_PORT, -}: StartStudioOptions): Promise => { - const server = await createServer(createStudioViteConfig({ projectRoot, port })); - - await server.listen(); - - const resolvedPort = server.config.server.port ?? port; - const url = `http://localhost:${resolvedPort}`; - - return { - close: () => server.close(), - port: resolvedPort, - server, - url, - }; -}; +}: StartStudioOptions): Effect.Effect => + Effect.gen(function* () { + const server = yield* Effect.tryPromise({ + try: () => createServer(createStudioViteConfig({ projectRoot, port })), + catch: (cause) => new StudioStartError({ message: causeMessage(cause) }), + }); + + yield* Effect.tryPromise({ + try: () => server.listen(), + catch: (cause) => new StudioStartError({ message: causeMessage(cause) }), + }); + + const resolvedPort = server.config.server.port ?? port; + const url = `http://localhost:${resolvedPort}`; + + return { + close: () => server.close(), + port: resolvedPort, + server, + url, + }; + }); diff --git a/apps/studio/src/server/virtual-paywalls-plugin.ts b/apps/studio/src/server/virtual-paywalls-plugin.ts index 7bbb678c8..c0d80ea2a 100644 --- a/apps/studio/src/server/virtual-paywalls-plugin.ts +++ b/apps/studio/src/server/virtual-paywalls-plugin.ts @@ -1,8 +1,14 @@ -import { existsSync, readdirSync, statSync } from "node:fs"; -import { basename, join } from "node:path"; - +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Path, type PlatformError, Schema } from "effect"; import type { Plugin, ViteDevServer } from "vite"; +// The POSIX `Path` service, resolved once so Vite's synchronous plugin hooks +// (`config`, watcher callbacks) can join paths without running an Effect. +const path = Effect.runSync(Effect.provide(Path.Path, Path.layer)); + +/** Serializes a value as a JSON literal for the generated virtual module. */ +const jsonLiteral = Schema.encodeSync(Schema.UnknownFromJsonString); + /** * The id Studio imports to discover the user's paywalls and components. Resolved * by {@link voidhashPaywallsPlugin} into a module of eager imports so titles and @@ -29,29 +35,43 @@ interface SourceEntry { const isSourceFile = (name: string): boolean => SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext)) && !name.endsWith(".d.ts"); -const idFromFile = (file: string): string => basename(file).replace(/\.(tsx|jsx|ts|js)$/, ""); +const idFromFile = (file: string): string => path.basename(file).replace(/\.(tsx|jsx|ts|js)$/, ""); /** Recursively lists files under a directory (absolute paths). */ -const listFilesRecursive = (dir: string): string[] => { - if (!existsSync(dir)) return []; - const out: string[] = []; - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - if (statSync(full).isDirectory()) { - out.push(...listFilesRecursive(full)); - } else { - out.push(full); +const listFilesRecursive = ( + dir: string, +): Effect.Effect, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(dir); + if (!exists) return []; + + const out: Array = []; + for (const entry of yield* fs.readDirectory(dir)) { + const full = path.join(dir, entry); + const info = yield* fs.stat(full); + if (info.type === "Directory") { + out.push(...(yield* listFilesRecursive(full))); + } else { + out.push(full); + } } - } - return out; -}; + return out; + }); /** Lists the source files under a `.voidhash/
` tree, sorted by id. */ -const scanDir = (voidhashDir: string, dir: string): SourceEntry[] => - listFilesRecursive(join(voidhashDir, dir)) - .filter((file) => isSourceFile(basename(file))) - .map((file) => ({ file, id: idFromFile(file) })) - .sort((a, b) => a.id.localeCompare(b.id)); +const scanDir = ( + voidhashDir: string, + dir: string, +): Effect.Effect, PlatformError.PlatformError, FileSystem.FileSystem> => + listFilesRecursive(path.join(voidhashDir, dir)).pipe( + Effect.map((files) => + files + .filter((file) => isSourceFile(path.basename(file))) + .map((file) => ({ file, id: idFromFile(file) })) + .sort((a, b) => a.id.localeCompare(b.id)), + ), + ); /** * Vite reference to a filesystem-absolute path. Files live in the *user's* @@ -63,28 +83,28 @@ const fsImportSpecifier = (absPath: string): string => `/@fs${absPath}`; /** Generates the source of the virtual module from the discovered files. */ const generateModule = ( projectRoot: string, - paywalls: SourceEntry[], - components: SourceEntry[], + paywalls: ReadonlyArray, + components: ReadonlyArray, ): string => { const lines: string[] = []; const paywallRefs: string[] = []; const componentRefs: string[] = []; paywalls.forEach((entry, i) => { - lines.push(`import * as __pw${i} from ${JSON.stringify(fsImportSpecifier(entry.file))};`); + lines.push(`import * as __pw${i} from ${jsonLiteral(fsImportSpecifier(entry.file))};`); paywallRefs.push( - `{ id: ${JSON.stringify(entry.id)}, file: ${JSON.stringify(entry.file)}, module: __pw${i} }`, + `{ id: ${jsonLiteral(entry.id)}, file: ${jsonLiteral(entry.file)}, module: __pw${i} }`, ); }); components.forEach((entry, i) => { - lines.push(`import * as __cmp${i} from ${JSON.stringify(fsImportSpecifier(entry.file))};`); + lines.push(`import * as __cmp${i} from ${jsonLiteral(fsImportSpecifier(entry.file))};`); componentRefs.push( - `{ id: ${JSON.stringify(entry.id)}, file: ${JSON.stringify(entry.file)}, module: __cmp${i} }`, + `{ id: ${jsonLiteral(entry.id)}, file: ${jsonLiteral(entry.file)}, module: __cmp${i} }`, ); }); - lines.push(`export const projectRoot = ${JSON.stringify(projectRoot)};`); + lines.push(`export const projectRoot = ${jsonLiteral(projectRoot)};`); lines.push(`export const paywalls = [${paywallRefs.join(", ")}];`); lines.push(`export const components = [${componentRefs.join(", ")}];`); return lines.join("\n"); @@ -102,7 +122,7 @@ export interface VoidhashPaywallsPluginOptions { * file invalidates the virtual module and reloads so the sidebar stays in sync. */ export const voidhashPaywallsPlugin = ({ projectRoot }: VoidhashPaywallsPluginOptions): Plugin => { - const voidhashDir = join(projectRoot, ".voidhash"); + const voidhashDir = path.join(projectRoot, ".voidhash"); let server: ViteDevServer | undefined; const invalidate = () => { @@ -113,8 +133,8 @@ export const voidhashPaywallsPlugin = ({ projectRoot }: VoidhashPaywallsPluginOp }; const isPaywallSource = (file: string): boolean => - file.startsWith(join(voidhashDir, PAYWALLS_DIR)) || - file.startsWith(join(voidhashDir, COMPONENTS_DIR)); + file.startsWith(path.join(voidhashDir, PAYWALLS_DIR)) || + file.startsWith(path.join(voidhashDir, COMPONENTS_DIR)); return { name: "voidhash:paywalls", @@ -143,9 +163,13 @@ export const voidhashPaywallsPlugin = ({ projectRoot }: VoidhashPaywallsPluginOp load(id) { if (id !== RESOLVED_VIRTUAL_ID) return null; - const paywalls = scanDir(voidhashDir, PAYWALLS_DIR); - const components = scanDir(voidhashDir, COMPONENTS_DIR); - return generateModule(projectRoot, paywalls, components); + return Effect.runPromise( + Effect.gen(function* () { + const paywalls = yield* scanDir(voidhashDir, PAYWALLS_DIR); + const components = yield* scanDir(voidhashDir, COMPONENTS_DIR); + return generateModule(projectRoot, paywalls, components); + }).pipe(Effect.provide(NodeServices.layer), Effect.orDie), + ); }, }; }; diff --git a/apps/studio/src/voidhash/paywalls.ts b/apps/studio/src/voidhash/paywalls.ts index bbc819b53..1d9309a0a 100644 --- a/apps/studio/src/voidhash/paywalls.ts +++ b/apps/studio/src/voidhash/paywalls.ts @@ -3,6 +3,7 @@ import { paywalls as rawPaywalls, projectRoot as rawProjectRoot, } from "virtual:voidhash-paywalls"; +import { causeMessage } from "@voidhash/lib/lang"; import { type ActionMap, type ComponentDefinition, @@ -13,6 +14,12 @@ import { type PaywallDefinition, type PropMap, } from "@voidhash/paywalls"; +import { Data, Effect } from "effect"; + +/** Raised when a component definition's §2 manifest cannot be extracted. */ +class ManifestExtractionError extends Data.TaggedError("ManifestExtractionError")<{ + readonly message: string; +}> {} /** * A component definition with its prop/action generics erased — the shape of @@ -71,15 +78,15 @@ const findComponentDefinition = ( return null; }; -const safeManifest = (definition: AnyComponentDefinition): ComponentManifest | null => { - try { - return extractComponentManifest(definition); - } catch { - // A structurally valid definition with hand-rolled (non-builder) props can - // still blow up extraction; the sidebar then just omits the metadata. - return null; - } -}; +// A structurally valid definition with hand-rolled (non-builder) props can +// still blow up extraction; the sidebar then just omits the metadata. +const safeManifest = (definition: AnyComponentDefinition): ComponentManifest | null => + Effect.runSync( + Effect.try({ + try: () => extractComponentManifest(definition), + catch: (cause) => new ManifestExtractionError({ message: causeMessage(cause) }), + }).pipe(Effect.orElseSucceed(() => null)), + ); /** * Normalizes the raw `virtual:voidhash-paywalls` module into typed, validated @@ -112,12 +119,10 @@ export const loadProjectContent = (): ProjectContent => { const components: ComponentEntry[] = rawComponents.map((entry) => { const definition = findComponentDefinition(entry.module); - return { - definition, - file: entry.file, - id: entry.id, - manifest: definition ? safeManifest(definition) : null, - }; + if (!definition) { + return { definition: null, file: entry.file, id: entry.id, manifest: null }; + } + return { definition, file: entry.file, id: entry.id, manifest: safeManifest(definition) }; }); return { diff --git a/apps/studio/src/voidhash/preview-runtime.ts b/apps/studio/src/voidhash/preview-runtime.ts index af980b9cf..fda69e6f9 100644 --- a/apps/studio/src/voidhash/preview-runtime.ts +++ b/apps/studio/src/voidhash/preview-runtime.ts @@ -1,4 +1,5 @@ import type { PaywallBridge, PaywallOutboundEnvelope } from "@voidhash/paywalls"; +import { Clock, Effect } from "effect"; import { DEFAULT_PREVIEW_DEVICE_PROFILE, previewConfigForDevice } from "./preview-devices"; @@ -33,7 +34,7 @@ let eventCounter = 0; export const createStudioBridge = (onEvent: (event: PreviewEvent) => void): PaywallBridge => ({ post: (envelope) => { eventCounter += 1; - onEvent({ at: Date.now(), envelope, key: eventCounter }); + onEvent({ at: Effect.runSync(Clock.currentTimeMillis), envelope, key: eventCounter }); }, subscribe: () => () => {}, }); diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts index 31782d607..a19060e9e 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -1,4 +1,4 @@ -import { resolve } from "node:path"; +import { Config, Effect, Path } from "effect"; import { createStudioViteConfig } from "./src/server/config"; @@ -9,9 +9,13 @@ import { createStudioViteConfig } from "./src/server/config"; * falls back to the bundled React Native example so Studio can be developed in * isolation. */ -const projectRoot = - process.env.VOIDHASH_PROJECT_ROOT ?? - resolve(import.meta.dirname, "../../examples/react-native-example"); +const projectRoot = Effect.runSync( + Effect.gen(function* () { + const path = yield* Path.Path; + const fallback = path.resolve(import.meta.dirname, "../../examples/react-native-example"); + return yield* Config.string("VOIDHASH_PROJECT_ROOT").pipe(Config.withDefault(fallback)); + }).pipe(Effect.provide(Path.layer), Effect.orDie), +); export default createStudioViteConfig({ projectRoot, diff --git a/apps/www/scripts/generate-openapi.ts b/apps/www/scripts/generate-openapi.ts index 1f7001238..2354f1db1 100644 --- a/apps/www/scripts/generate-openapi.ts +++ b/apps/www/scripts/generate-openapi.ts @@ -1,7 +1,7 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; -import path from "node:path"; import { fileURLToPath } from "node:url"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Path } from "effect"; import { generateFiles } from "fumadocs-openapi"; import { createOpenAPI } from "fumadocs-openapi/server"; @@ -12,6 +12,10 @@ import { createOpenAPI } from "fumadocs-openapi/server"; */ const API_URL = process.env.VOIDHASH_API_URL ?? "https://api.voidhash.com"; +// The path service is needed while building module-level constants, so it is +// materialized once synchronously rather than threaded through every effect. +const path = Effect.runSync(Effect.provide(Path.Path, Path.layer)); + const appRoot = fileURLToPath(new URL("..", import.meta.url)); // Relative (to the app root) so the `document`/preload ids baked into generated // pages are portable across machines rather than absolute local paths. @@ -45,42 +49,52 @@ const surfaces: Surface[] = [ }, ]; -async function fetchSpec(surface: Surface): Promise { - const url = `${API_URL}${surface.remotePath}`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); - } - const document = await response.json(); - await writeFile(path.join(specDir, surface.specFile), `${JSON.stringify(document, null, 2)}\n`); - console.log(`✓ fetched ${surface.label} (${url})`); -} +const fetchSpec = (surface: Surface) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const url = `${API_URL}${surface.remotePath}`; + const response = yield* Effect.promise(() => fetch(url)); + if (!response.ok) { + return yield* Effect.die( + new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`), + ); + } + const document = yield* Effect.promise(() => response.json()); + yield* fs.writeFileString( + path.join(specDir, surface.specFile), + `${JSON.stringify(document, null, 2)}\n`, + ); + console.log(`✓ fetched ${surface.label} (${url})`); + }); -async function main(): Promise { - await mkdir(specDir, { recursive: true }); +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(specDir, { recursive: true }); for (const surface of surfaces) { - await fetchSpec(surface); + yield* fetchSpec(surface); } for (const surface of surfaces) { - await rm(surface.output, { force: true, recursive: true }); + yield* fs.remove(surface.output, { force: true, recursive: true }); // Keyed record input so generated pages reference the spec by a stable // `document` id (the key) rather than an absolute machine path. const server = createOpenAPI({ input: { [surface.key]: `${specDirRel}/${surface.specFile}` }, }); - await generateFiles({ - input: server, - output: surface.output, - per: "operation", - groupBy: "tag", - meta: true, - }); + yield* Effect.promise(() => + generateFiles({ + input: server, + output: surface.output, + per: "operation", + groupBy: "tag", + meta: true, + }), + ); console.log(`✓ generated ${surface.label} -> ${path.relative(appRoot, surface.output)}`); } -} +}); -main().catch((error) => { +Effect.runPromise(main.pipe(Effect.provide(NodeServices.layer))).catch((error) => { console.error(error); process.exit(1); }); diff --git a/apps/www/src/components/default-catch-boundary.tsx b/apps/www/src/components/default-catch-boundary.tsx index 1702d6503..fee750ed0 100644 --- a/apps/www/src/components/default-catch-boundary.tsx +++ b/apps/www/src/components/default-catch-boundary.tsx @@ -15,7 +15,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
diff --git a/apps/www/src/features/studio/ai/components/tool-call.tsx b/apps/www/src/features/studio/ai/components/tool-call.tsx index 428e359b5..e11ff3314 100644 --- a/apps/www/src/features/studio/ai/components/tool-call.tsx +++ b/apps/www/src/features/studio/ai/components/tool-call.tsx @@ -1,4 +1,5 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger, cn, Spinner } from "@voidhash/ui"; +import { Effect } from "effect"; import { AlertCircle, ChevronRight, CheckCircle2, Wrench } from "lucide-react"; import type { AgentUiToolPart } from "../agent-ui"; @@ -11,11 +12,13 @@ function formatValue(value: unknown): string { if (typeof value === "string") { return value; } - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } + // Cyclic or BigInt-bearing values cannot be serialized; there is no useful + // string form for them, so label the type rather than render "[object Object]". + return Effect.runSync( + Effect.try(() => JSON.stringify(value, null, 2)).pipe( + Effect.orElseSucceed(() => `[unserializable ${typeof value}]`), + ), + ); } interface ToolOutputImage { diff --git a/apps/www/src/features/studio/ai/use-agent-session.ts b/apps/www/src/features/studio/ai/use-agent-session.ts index de552d33e..eff4d13cd 100644 --- a/apps/www/src/features/studio/ai/use-agent-session.ts +++ b/apps/www/src/features/studio/ai/use-agent-session.ts @@ -4,6 +4,7 @@ import { type AgentServerMessage, } from "@voidhash/agent/Protocol"; import { useQueryClient } from "@tanstack/react-query"; +import { Effect } from "effect"; import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; import { queryKeys } from "@/features/studio/lib/tanstack-query"; @@ -61,14 +62,12 @@ const requestId = (): string => crypto.randomUUID(); const parseServerMessage = (data: unknown): AgentServerMessage | undefined => { if (typeof data !== "string") return undefined; - try { - const parsed: unknown = JSON.parse(data); - return typeof parsed === "object" && parsed !== null && "type" in parsed && "v" in parsed - ? (parsed as AgentServerMessage) - : undefined; - } catch { - return undefined; - } + const parsed = Effect.runSync( + Effect.try((): unknown => JSON.parse(data)).pipe(Effect.orElseSucceed(() => undefined)), + ); + return typeof parsed === "object" && parsed !== null && "type" in parsed && "v" in parsed + ? (parsed as AgentServerMessage) + : undefined; }; const dynamicContext = (agent: SurfaceAgent) => { diff --git a/apps/www/src/features/studio/analytics/custom-dashboards-page.tsx b/apps/www/src/features/studio/analytics/custom-dashboards-page.tsx index d735258b1..04805d83c 100644 --- a/apps/www/src/features/studio/analytics/custom-dashboards-page.tsx +++ b/apps/www/src/features/studio/analytics/custom-dashboards-page.tsx @@ -5,6 +5,7 @@ import type { SavedAnalyticsInsightType, } from "@voidhash/rpc"; import { INTERNAL_FEATURE_FLAGS } from "@voidhash/rpc"; +import { Effect } from "effect"; import { Button, Card, @@ -276,10 +277,19 @@ function DashboardEditor({ const refresh = () => queryClient.invalidateQueries({ queryKey: queryKeys.analytics.dashboards({ projectId }) }); const refreshCards = async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: queryKeys.analytics.all }), - queryClient.invalidateQueries({ queryKey: ["runSavedVoidQlInsight"] }), - ]); + await Effect.runPromise( + Effect.all( + [ + Effect.promise(() => + queryClient.invalidateQueries({ queryKey: queryKeys.analytics.all }), + ), + Effect.promise(() => + queryClient.invalidateQueries({ queryKey: ["runSavedVoidQlInsight"] }), + ), + ], + { concurrency: "unbounded" }, + ), + ); }; const addInsight = () => { @@ -653,7 +663,11 @@ function DashboardVoidQlCard({ function formatVoidQlCell(value: unknown): string { if (value === null || value === undefined) return ""; - return typeof value === "object" ? JSON.stringify(value) : String(value); + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value.toString(); + } + return JSON.stringify(value); } function DashboardInsightCard({ @@ -980,7 +994,7 @@ function DashboardInsightCard({ ? query.isLoading ? "Loading insight…" : "No values in this range" - : `${insight.kind} execution is coming next`} + : `${String(insight.kind)} execution is coming next`} )} diff --git a/apps/www/src/features/studio/analytics/custom-insights-page.tsx b/apps/www/src/features/studio/analytics/custom-insights-page.tsx index 83aa659e3..f40f6ba7f 100644 --- a/apps/www/src/features/studio/analytics/custom-insights-page.tsx +++ b/apps/www/src/features/studio/analytics/custom-insights-page.tsx @@ -858,7 +858,7 @@ function InsightBuilder({ onClose, projectId }: { onClose: () => void; projectId const addSeries = () => setSeries((current) => { - const key = [..."ABCDEFGH"].find( + const key = Array.from("ABCDEFGH").find( (candidate) => !current.some((item) => item.key === candidate), ); return key ? [...current, { aggregation: "unique_users", eventName: "", key }] : current; @@ -871,7 +871,7 @@ function InsightBuilder({ onClose, projectId }: { onClose: () => void; projectId const addFunnelStep = () => setFunnelSteps((current) => { - const key = [..."ABCDEFGH"].find( + const key = Array.from("ABCDEFGH").find( (candidate) => !current.some((step) => step.key === candidate), ); return key ? [...current, { eventName: "", key }] : current; diff --git a/apps/www/src/features/studio/api-keys/api-key-record.tsx b/apps/www/src/features/studio/api-keys/api-key-record.tsx index daa6c59e8..c98befcae 100644 --- a/apps/www/src/features/studio/api-keys/api-key-record.tsx +++ b/apps/www/src/features/studio/api-keys/api-key-record.tsx @@ -30,7 +30,7 @@ export function ApiKeyRecord({ apiKey }: { apiKey: typeof ApiKey.Type }) { const { ConfirmationDialog, openDialog } = useConfirmDialog(); const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); + void navigator.clipboard.writeText(text); toast.success("Copied to clipboard"); }; @@ -39,7 +39,7 @@ export function ApiKeyRecord({ apiKey }: { apiKey: typeof ApiKey.Type }) { ...rotateSecretKeyOptions(), onSuccess: () => { toast.success("Key successfully rotated"); - queryClient.invalidateQueries({ queryKey: queryKeys.apiKey.all }); + void queryClient.invalidateQueries({ queryKey: queryKeys.apiKey.all }); }, onError: () => { toast.error("Failed to rotate key"); @@ -65,7 +65,7 @@ export function ApiKeyRecord({ apiKey }: { apiKey: typeof ApiKey.Type }) { ...deleteApiKeyOptions(), onSuccess: () => { toast.success("Key successfully deleted"); - queryClient.invalidateQueries({ queryKey: queryKeys.apiKey.all }); + void queryClient.invalidateQueries({ queryKey: queryKeys.apiKey.all }); }, onError: () => { toast.error("Failed to delete key"); diff --git a/apps/www/src/features/studio/api-keys/create-secret-key-modal.tsx b/apps/www/src/features/studio/api-keys/create-secret-key-modal.tsx index ce275242e..49b21b595 100644 --- a/apps/www/src/features/studio/api-keys/create-secret-key-modal.tsx +++ b/apps/www/src/features/studio/api-keys/create-secret-key-modal.tsx @@ -63,7 +63,7 @@ export function CreateSecretKeyModal({ onSuccess: (data) => { onSuccess?.(data); toast.success("Key successfully created"); - queryClient.invalidateQueries({ queryKey: queryKeys.apiKey.all }); + void queryClient.invalidateQueries({ queryKey: queryKeys.apiKey.all }); }, onError: () => { toast.error("Failed to create key"); diff --git a/apps/www/src/features/studio/components/auth-context.tsx b/apps/www/src/features/studio/components/auth-context.tsx index 2d8e5b4cc..5429c6ed5 100644 --- a/apps/www/src/features/studio/components/auth-context.tsx +++ b/apps/www/src/features/studio/components/auth-context.tsx @@ -1,4 +1,5 @@ import type { User } from "@voidhash/rpc"; +import { Effect } from "effect"; import { type ReactNode, createContext, useContext } from "react"; interface AuthContextType { @@ -20,7 +21,7 @@ export function AuthProvider({ children, user }: AuthProviderProps) { export function useAuth(): AuthContextType { const context = useContext(AuthContext); if (context === undefined) { - throw new Error("useAuth must be used within an AuthProvider"); + return Effect.runSync(Effect.die(new Error("useAuth must be used within an AuthProvider"))); } return context; } diff --git a/apps/www/src/features/studio/components/avatar-uploader.tsx b/apps/www/src/features/studio/components/avatar-uploader.tsx index 17bca75a9..d9e8830f7 100644 --- a/apps/www/src/features/studio/components/avatar-uploader.tsx +++ b/apps/www/src/features/studio/components/avatar-uploader.tsx @@ -11,6 +11,7 @@ import { GradientAvatar, Slider, } from "@voidhash/ui"; +import { Effect } from "effect"; import { useCallback, useRef, useState } from "react"; import Cropper, { type Area } from "react-easy-crop"; import { toast } from "sonner"; @@ -24,24 +25,24 @@ const ACCEPTED_TYPES = "image/png,image/jpeg,image/webp"; /** Edge length the cropped square is resized to before upload (keeps payloads tiny). */ const OUTPUT_SIZE = 512; -const createImage = (url: string): Promise => - new Promise((resolve, reject) => { +const createImage = (url: string): Effect.Effect => + Effect.callback((resume) => { const image = new Image(); - image.addEventListener("load", () => resolve(image)); - image.addEventListener("error", (error) => reject(error)); + image.addEventListener("load", () => resume(Effect.succeed(image))); + image.addEventListener("error", (error) => resume(Effect.fail(error))); image.src = url; }); -const blobToBase64 = (blob: Blob): Promise => - new Promise((resolve, reject) => { +const blobToBase64 = (blob: Blob): Effect.Effect => + Effect.callback((resume) => { const reader = new FileReader(); reader.onloadend = () => { const result = reader.result as string; // Strip the `data:;base64,` prefix — the server accepts raw base64. const comma = result.indexOf(","); - resolve(comma === -1 ? result : result.slice(comma + 1)); + resume(Effect.succeed(comma === -1 ? result : result.slice(comma + 1))); }; - reader.onerror = reject; + reader.onerror = (error) => resume(Effect.fail(error)); reader.readAsDataURL(blob); }); @@ -49,38 +50,39 @@ const blobToBase64 = (blob: Blob): Promise => * Draws the selected crop area to an offscreen canvas, resizing to a fixed * square, and returns the result as a base64 WebP (no data-URL prefix). */ -const getCroppedImage = async ( +const getCroppedImage = ( imageSrc: string, pixelCrop: Area, -): Promise<{ imageBase64: string; contentType: string }> => { - const image = await createImage(imageSrc); - const canvas = document.createElement("canvas"); - canvas.width = OUTPUT_SIZE; - canvas.height = OUTPUT_SIZE; - const ctx = canvas.getContext("2d"); - if (!ctx) { - throw new Error("Could not get canvas context"); - } - ctx.drawImage( - image, - pixelCrop.x, - pixelCrop.y, - pixelCrop.width, - pixelCrop.height, - 0, - 0, - OUTPUT_SIZE, - OUTPUT_SIZE, - ); - const blob = await new Promise((resolve) => - canvas.toBlob(resolve, "image/webp", 0.85), - ); - if (!blob) { - throw new Error("Could not produce cropped image"); - } - const imageBase64 = await blobToBase64(blob); - return { imageBase64, contentType: "image/webp" }; -}; +): Effect.Effect<{ imageBase64: string; contentType: string }, unknown> => + Effect.gen(function* () { + const image = yield* createImage(imageSrc); + const canvas = document.createElement("canvas"); + canvas.width = OUTPUT_SIZE; + canvas.height = OUTPUT_SIZE; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return yield* Effect.fail(new Error("Could not get canvas context")); + } + ctx.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + OUTPUT_SIZE, + OUTPUT_SIZE, + ); + const blob = yield* Effect.callback((resume) => { + canvas.toBlob((value) => resume(Effect.succeed(value)), "image/webp", 0.85); + }); + if (!blob) { + return yield* Effect.fail(new Error("Could not produce cropped image")); + } + const imageBase64 = yield* blobToBase64(blob); + return { imageBase64, contentType: "image/webp" }; + }); export interface AvatarUploaderProps { /** Display name (used for alt text). */ @@ -145,15 +147,20 @@ export function AvatarUploader({ return; } setIsSaving(true); - try { - const result = await getCroppedImage(imageSrc, croppedAreaPixels); - onUpload(result); - closeDialog(); - } catch { - toast.error("Failed to process the image. Please try another file."); - } finally { - setIsSaving(false); - } + await Effect.runPromise( + Effect.gen(function* () { + const result = yield* getCroppedImage(imageSrc, croppedAreaPixels); + onUpload(result); + closeDialog(); + }).pipe( + Effect.catchCause(() => + Effect.sync(() => { + toast.error("Failed to process the image. Please try another file."); + }), + ), + Effect.ensuring(Effect.sync(() => setIsSaving(false))), + ), + ); }; return ( diff --git a/apps/www/src/features/studio/components/default-cache-boundary.tsx b/apps/www/src/features/studio/components/default-cache-boundary.tsx index 3dabea7b9..730c622d7 100644 --- a/apps/www/src/features/studio/components/default-cache-boundary.tsx +++ b/apps/www/src/features/studio/components/default-cache-boundary.tsx @@ -15,7 +15,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {