From fdd5bca7efa1261c51e6ed8001905dd4a3393d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Sat, 1 Aug 2026 16:39:12 +0200 Subject: [PATCH 1/7] feat: platform-independent runtime adapters and standalone test suite Replace the orbian integration with first-party platform contracts, add an Effect Cluster adapter, and make this repository fully standalone: it runs its own self-host stack and every test suite without the private repository. Platform - Restore `@voidhash/platform` contracts and `@voidhash/platform-node`, and drop every `@orbian/*` dependency, workspace escape, and release script. - Add `@voidhash/platform-cluster`: durable workflows on ClusterWorkflowEngine, cron on ClusterCron, queues on PersistedQueue, and a durable entity host on Sharding, with single-node and in-memory topologies. - Extract the shared Effect workflow-engine mapping to `@voidhash/platform/EffectWorkflowRunner` so node and cluster adapters differ only in the engine they install. - Add `@voidhash/platform/conformance`: one suite every adapter must pass, run against the memory, node, and cluster backends. Self-host as the development environment - Add a compose dev overlay publishing Postgres and the compiler to the host, `pnpm stack:up`, and `pnpm test:integration` covering eight suites. - Document every variable, including operator-provided ones, in `.env.example`. Standalone tests - Move the core integration harness into `packages/core/test/_testing/`, with `CoreTestConnections` as the environment contract a composition injects and an env-driven global setup over the self-host stack. - Stop excluding integration tests from typecheck. --- .dockerignore | 5 + apps/backend/package.json | 5 +- .../backend/src/rpc-smoke.integration.test.ts | 2 +- apps/backend/vitest.integration.mts | 23 + apps/mimic-db/package.json | 2 +- apps/mimic-db/src/core/local-entity-host.ts | 2 +- apps/mimic-db/src/core/local-host-service.ts | 2 +- .../tests/durable-entity-host.test.ts | 2 +- .../tests/unit/direct-migration.test.ts | 2 +- docs/architecture.md | 25 +- package.json | 4 +- packages/agent/package.json | 4 +- packages/agent/src/AgentSessionCore.ts | 2 +- packages/agent/src/SessionLog.ts | 2 +- packages/agent/tests/AgentSessionCore.test.ts | 4 +- .../tests/AgentSessionPg.integration.test.ts | 6 +- packages/agent/tests/SessionLog.test.ts | 2 +- packages/agent/tests/workerd-do-probe.ts | 2 +- packages/core/package.json | 5 +- .../AnalyticsDispatchService.ts | 2 +- .../analyticsIngest/CaptureIngress.ts | 2 +- .../analyticsIngest/PolicyCounterStore.ts | 2 +- .../services/infrastructure/QueueProducer.ts | 4 +- .../notifications/PushDeliveryDispatch.ts | 2 +- .../core/test/_testing/CoreAuthSession.ts | 70 ++ .../_testing/CoreIntegrationTestHarness.ts | 207 +++++ .../core/test/_testing/CoreTestConnections.ts | 72 ++ .../core/test/_testing/CoreTestFixture.ts | 28 + packages/core/test/_testing/CoreTestSeed.ts | 142 ++++ .../PurchaseIntegrationTestHarness.ts | 22 + .../_testing/ReactNativePurchaseHarness.ts | 108 +++ packages/core/test/_testing/globalSetup.ts | 45 ++ .../core/test/_testing/provided-context.d.ts | 11 + .../core/test/_testing/purchaseGlobalSetup.ts | 19 + .../integration-harness.integration.test.ts | 9 +- packages/core/test/runtime-context-types.ts | 2 +- ...lyticsIngestDlqService.integration.test.ts | 2 +- .../EventCaptureService.integration.test.ts | 2 +- ...aseLedgerWorkerService.integration.test.ts | 2 +- packages/core/tsconfig.json | 9 +- packages/core/vitest.integration.mts | 32 + packages/platform/LICENSE.md | 661 ++++++++++++++++ packages/platform/package.json | 40 + packages/platform/src/CronScheduler.ts | 89 +++ packages/platform/src/DurableEntity.ts | 77 ++ packages/platform/src/EffectWorkflowRunner.ts | 227 ++++++ packages/platform/src/KeyValueStore.ts | 79 ++ packages/platform/src/Mailer.ts | 49 ++ packages/platform/src/ObjectStore.ts | 56 ++ packages/platform/src/PlatformRuntime.ts | 11 + packages/platform/src/Primitive.ts | 11 + packages/platform/src/Queue.ts | 126 ++++ packages/platform/src/Screenshot.ts | 33 + packages/platform/src/Workflow.ts | 207 +++++ .../platform/src/conformance/CronScheduler.ts | 117 +++ .../platform/src/conformance/DurableEntity.ts | 160 ++++ packages/platform/src/conformance/Queue.ts | 167 ++++ packages/platform/src/conformance/Workflow.ts | 169 +++++ packages/platform/src/conformance/index.ts | 13 + packages/platform/src/index.ts | 78 ++ packages/platform/tsconfig.json | 5 + pnpm-lock.yaml | 208 ++--- scripts/check-selfhost-runtime-boundary.mjs | 11 +- scripts/db-migrate-local.mjs | 18 +- scripts/run-local-integration.mjs | 152 ++++ scripts/set-orbian-source.mjs | 154 ---- selfhost/.env.example | 42 ++ selfhost/README.md | 24 + selfhost/docker-compose.dev.yml | 24 + selfhost/entry/Dockerfile | 4 +- selfhost/entry/package.json | 4 +- selfhost/entry/src/DurableEntityAlarms.ts | 4 +- .../entry/src/agent/AgentNodeWebSocket.ts | 4 +- selfhost/entry/src/backend/Analytics.ts | 12 +- selfhost/entry/src/backend/Backend.ts | 2 +- selfhost/entry/src/backend/Background.ts | 6 +- .../src/backend/IdentityCompletionWorkflow.ts | 2 +- selfhost/entry/src/backend/ObjectStores.ts | 6 +- .../src/backend/PaymentProviderWorkflows.ts | 2 +- selfhost/entry/src/backend/Push.ts | 4 +- selfhost/entry/src/backend/Thumbnails.ts | 10 +- .../src/backend/WebhookDeliveryWorkflow.ts | 2 +- .../entry/src/backend/WorkflowDefinitions.ts | 2 +- selfhost/entry/src/backend/WorkflowPorts.ts | 8 +- selfhost/entry/src/config.ts | 30 +- selfhost/entry/src/index.ts | 6 +- selfhost/entry/src/main.ts | 6 +- selfhost/entry/src/migrate.ts | 7 +- .../entry/src/mimic/MimicDocumentIdleQueue.ts | 4 +- selfhost/entry/src/mimic/MimicNode.ts | 4 +- .../entry/src/mimic/MimicNodeWebSocket.ts | 6 +- selfhost/entry/src/mimic/PgControlStore.ts | 2 +- selfhost/entry/src/mimic/config.ts | 35 +- selfhost/entry/src/mimic/main.ts | 8 +- .../AgentNodeWebSocket.integration.test.ts | 2 +- selfhost/entry/tests/BackendAdapters.test.ts | 43 +- .../tests/Background.integration.test.ts | 7 +- .../entry/tests/MimicDocumentIdle.test.ts | 6 +- .../entry/tests/MimicNode.integration.test.ts | 4 +- selfhost/platform-cluster/package.json | 36 + .../platform-cluster/src/CronScheduler.ts | 179 +++++ .../platform-cluster/src/DurableEntity.ts | Bin 0 -> 4771 bytes .../platform-cluster/src/PlatformRuntime.ts | 13 + selfhost/platform-cluster/src/Queue.ts | 292 +++++++ selfhost/platform-cluster/src/Topology.ts | 44 ++ selfhost/platform-cluster/src/Workflow.ts | 17 + selfhost/platform-cluster/src/index.ts | 9 + .../tests/SingleNodePg.integration.test.ts | 141 ++++ .../platform-cluster/tests/cluster.test.ts | 185 +++++ .../tests/conformance.test.ts | 50 ++ selfhost/platform-cluster/tsconfig.json | 12 + selfhost/platform-cluster/vitest.mts | 10 + selfhost/platform-node/package.json | 42 ++ selfhost/platform-node/src/CronScheduler.ts | 249 ++++++ selfhost/platform-node/src/DurableEntity.ts | 256 +++++++ selfhost/platform-node/src/KeyValueStore.ts | 252 +++++++ selfhost/platform-node/src/Mailer.ts | 144 ++++ .../platform-node/src/MemoryDurableEntity.ts | 73 ++ .../src/NodeDurableEntitySession.ts | 36 + selfhost/platform-node/src/ObjectStore.ts | 142 ++++ .../platform-node/src/PgWorkflowEngine.ts | 710 ++++++++++++++++++ selfhost/platform-node/src/PlatformRuntime.ts | 6 + selfhost/platform-node/src/Postgres.ts | 24 + selfhost/platform-node/src/Queue.ts | 342 +++++++++ selfhost/platform-node/src/Screenshot.ts | 143 ++++ selfhost/platform-node/src/Workflow.ts | 10 + selfhost/platform-node/src/index.ts | 28 + .../ChromiumScreenshot.integration.test.ts | 96 +++ .../tests/MemoryDurableEntity.test.ts | 73 ++ .../MemoryDurableEntityConformance.test.ts | 10 + .../tests/NodeDurableEntitySession.test.ts | 27 + .../tests/PgCronScheduler.integration.test.ts | 215 ++++++ .../tests/PgDurableEntity.integration.test.ts | 127 ++++ .../tests/PgKeyValueStore.integration.test.ts | 140 ++++ .../tests/PgQueue.integration.test.ts | 200 +++++ .../PgWorkflowRunner.integration.test.ts | 217 ++++++ .../tests/S3ObjectStore.integration.test.ts | 83 ++ .../platform-node/tests/Screenshot.test.ts | 42 ++ .../tests/SmtpMailer.integration.test.ts | 134 ++++ selfhost/platform-node/tsconfig.json | 12 + selfhost/platform-node/vitest.mts | 10 + 141 files changed, 8586 insertions(+), 390 deletions(-) create mode 100644 apps/backend/vitest.integration.mts create mode 100644 packages/core/test/_testing/CoreAuthSession.ts create mode 100644 packages/core/test/_testing/CoreIntegrationTestHarness.ts create mode 100644 packages/core/test/_testing/CoreTestConnections.ts create mode 100644 packages/core/test/_testing/CoreTestFixture.ts create mode 100644 packages/core/test/_testing/CoreTestSeed.ts create mode 100644 packages/core/test/_testing/PurchaseIntegrationTestHarness.ts create mode 100644 packages/core/test/_testing/ReactNativePurchaseHarness.ts create mode 100644 packages/core/test/_testing/globalSetup.ts create mode 100644 packages/core/test/_testing/provided-context.d.ts create mode 100644 packages/core/test/_testing/purchaseGlobalSetup.ts create mode 100644 packages/core/vitest.integration.mts create mode 100644 packages/platform/LICENSE.md create mode 100644 packages/platform/package.json create mode 100644 packages/platform/src/CronScheduler.ts create mode 100644 packages/platform/src/DurableEntity.ts create mode 100644 packages/platform/src/EffectWorkflowRunner.ts create mode 100644 packages/platform/src/KeyValueStore.ts create mode 100644 packages/platform/src/Mailer.ts create mode 100644 packages/platform/src/ObjectStore.ts create mode 100644 packages/platform/src/PlatformRuntime.ts create mode 100644 packages/platform/src/Primitive.ts create mode 100644 packages/platform/src/Queue.ts create mode 100644 packages/platform/src/Screenshot.ts create mode 100644 packages/platform/src/Workflow.ts create mode 100644 packages/platform/src/conformance/CronScheduler.ts create mode 100644 packages/platform/src/conformance/DurableEntity.ts create mode 100644 packages/platform/src/conformance/Queue.ts create mode 100644 packages/platform/src/conformance/Workflow.ts create mode 100644 packages/platform/src/conformance/index.ts create mode 100644 packages/platform/src/index.ts create mode 100644 packages/platform/tsconfig.json create mode 100644 scripts/run-local-integration.mjs delete mode 100644 scripts/set-orbian-source.mjs create mode 100644 selfhost/docker-compose.dev.yml create mode 100644 selfhost/platform-cluster/package.json create mode 100644 selfhost/platform-cluster/src/CronScheduler.ts create mode 100644 selfhost/platform-cluster/src/DurableEntity.ts create mode 100644 selfhost/platform-cluster/src/PlatformRuntime.ts create mode 100644 selfhost/platform-cluster/src/Queue.ts create mode 100644 selfhost/platform-cluster/src/Topology.ts create mode 100644 selfhost/platform-cluster/src/Workflow.ts create mode 100644 selfhost/platform-cluster/src/index.ts create mode 100644 selfhost/platform-cluster/tests/SingleNodePg.integration.test.ts create mode 100644 selfhost/platform-cluster/tests/cluster.test.ts create mode 100644 selfhost/platform-cluster/tests/conformance.test.ts create mode 100644 selfhost/platform-cluster/tsconfig.json create mode 100644 selfhost/platform-cluster/vitest.mts create mode 100644 selfhost/platform-node/package.json create mode 100644 selfhost/platform-node/src/CronScheduler.ts create mode 100644 selfhost/platform-node/src/DurableEntity.ts create mode 100644 selfhost/platform-node/src/KeyValueStore.ts create mode 100644 selfhost/platform-node/src/Mailer.ts create mode 100644 selfhost/platform-node/src/MemoryDurableEntity.ts create mode 100644 selfhost/platform-node/src/NodeDurableEntitySession.ts create mode 100644 selfhost/platform-node/src/ObjectStore.ts create mode 100644 selfhost/platform-node/src/PgWorkflowEngine.ts create mode 100644 selfhost/platform-node/src/PlatformRuntime.ts create mode 100644 selfhost/platform-node/src/Postgres.ts create mode 100644 selfhost/platform-node/src/Queue.ts create mode 100644 selfhost/platform-node/src/Screenshot.ts create mode 100644 selfhost/platform-node/src/Workflow.ts create mode 100644 selfhost/platform-node/src/index.ts create mode 100644 selfhost/platform-node/tests/ChromiumScreenshot.integration.test.ts create mode 100644 selfhost/platform-node/tests/MemoryDurableEntity.test.ts create mode 100644 selfhost/platform-node/tests/MemoryDurableEntityConformance.test.ts create mode 100644 selfhost/platform-node/tests/NodeDurableEntitySession.test.ts create mode 100644 selfhost/platform-node/tests/PgCronScheduler.integration.test.ts create mode 100644 selfhost/platform-node/tests/PgDurableEntity.integration.test.ts create mode 100644 selfhost/platform-node/tests/PgKeyValueStore.integration.test.ts create mode 100644 selfhost/platform-node/tests/PgQueue.integration.test.ts create mode 100644 selfhost/platform-node/tests/PgWorkflowRunner.integration.test.ts create mode 100644 selfhost/platform-node/tests/S3ObjectStore.integration.test.ts create mode 100644 selfhost/platform-node/tests/Screenshot.test.ts create mode 100644 selfhost/platform-node/tests/SmtpMailer.integration.test.ts create mode 100644 selfhost/platform-node/tsconfig.json create mode 100644 selfhost/platform-node/vitest.mts diff --git a/.dockerignore b/.dockerignore index c0962f05..b7f89a07 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,8 @@ **/.env **/.env.* *.log +**/ios/Pods +**/ios/build +**/android/build +**/android/.gradle +**/.expo diff --git a/apps/backend/package.json b/apps/backend/package.json index 39041a27..c1d15126 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -9,7 +9,8 @@ "typecheck": "tsc --noEmit", "typecheck-go": "tsgo --noEmit", "lint": "vp lint .", - "format": "vp fmt ." + "format": "vp fmt .", + "test:integration": "vp test run -c vitest.integration.mts" }, "dependencies": { "@voidhash/agent": "workspace:*", @@ -39,7 +40,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@orbian/sdk": "https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526", + "@voidhash/platform": "workspace:*", "@voidhash/tsconfig": "workspace:*", "alchemy": "catalog:", "typescript": "catalog:", diff --git a/apps/backend/src/rpc-smoke.integration.test.ts b/apps/backend/src/rpc-smoke.integration.test.ts index 2f65982a..335a493a 100644 --- a/apps/backend/src/rpc-smoke.integration.test.ts +++ b/apps/backend/src/rpc-smoke.integration.test.ts @@ -32,7 +32,7 @@ import { describe, expect, inject, test } from "vitest"; import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; import { PaywallArtifactStore, Workos } from "@voidhash/core/services"; import { Db } from "@voidhash/db"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { BackendComponentCompilerStubLive, diff --git a/apps/backend/vitest.integration.mts b/apps/backend/vitest.integration.mts new file mode 100644 index 00000000..e7c16259 --- /dev/null +++ b/apps/backend/vitest.integration.mts @@ -0,0 +1,23 @@ +import { defineConfig } from "vite-plus"; + +// Backend RPC + webhook smoke against a provisioned environment. Locally the +// self-host stack supplies it via the shared core globalSetup; downstream +// compositions substitute their own globalSetup providing the same +// `coreStackOutput` contract. +export default defineConfig({ + resolve: { + tsconfigPaths: true, + }, + test: { + include: ["./src/**/*.integration.test.ts"], + exclude: ["./node_modules/**"], + globalSetup: ["../../packages/core/test/_testing/globalSetup.ts"], + passWithNoTests: true, + pool: "threads", + fileParallelism: false, + hookTimeout: 300_000, + reporters: ["verbose"], + teardownTimeout: 300_000, + testTimeout: 120_000, + }, +}); diff --git a/apps/mimic-db/package.json b/apps/mimic-db/package.json index cac383a6..a66e8f2b 100644 --- a/apps/mimic-db/package.json +++ b/apps/mimic-db/package.json @@ -26,7 +26,7 @@ "@effect/sql-pg": "catalog:", "@voidhash/mimic-core": "workspace:*", "@voidhash/mimic-server": "workspace:*", - "@orbian/sdk": "https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526", + "@voidhash/platform": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/apps/mimic-db/src/core/local-entity-host.ts b/apps/mimic-db/src/core/local-entity-host.ts index 956d06f8..00a03d4d 100644 --- a/apps/mimic-db/src/core/local-entity-host.ts +++ b/apps/mimic-db/src/core/local-entity-host.ts @@ -3,7 +3,7 @@ import { type DurableEntityContext, type DurableEntityHostShape, type DurableEntitySession, -} from "@orbian/sdk/DurableEntity"; +} from "@voidhash/platform/DurableEntity"; import { Effect, Layer, Semaphore } from "effect"; interface MemoryEntityState { diff --git a/apps/mimic-db/src/core/local-host-service.ts b/apps/mimic-db/src/core/local-host-service.ts index 2b675c19..8258880e 100644 --- a/apps/mimic-db/src/core/local-host-service.ts +++ b/apps/mimic-db/src/core/local-host-service.ts @@ -2,7 +2,7 @@ import { type DurableEntityContext, DurableEntityHost, makeDurableEntityAddress, -} from "@orbian/sdk/DurableEntity"; +} from "@voidhash/platform/DurableEntity"; import { Effect, Layer } from "effect"; import type { MigrationRegistry } from "@voidhash/mimic-server/migrate"; import { NotFoundError } from "@voidhash/mimic-server/rpc"; diff --git a/apps/mimic-db/tests/durable-entity-host.test.ts b/apps/mimic-db/tests/durable-entity-host.test.ts index e17bc693..14d6ffb8 100644 --- a/apps/mimic-db/tests/durable-entity-host.test.ts +++ b/apps/mimic-db/tests/durable-entity-host.test.ts @@ -1,4 +1,4 @@ -import { makeDurableEntityAddress, type DurableEntitySession } from "@orbian/sdk/DurableEntity"; +import { makeDurableEntityAddress, type DurableEntitySession } from "@voidhash/platform/DurableEntity"; import { Effect } from "effect"; import { describe, expect, test } from "vitest"; diff --git a/apps/mimic-db/tests/unit/direct-migration.test.ts b/apps/mimic-db/tests/unit/direct-migration.test.ts index eca48b8f..2af9647c 100644 --- a/apps/mimic-db/tests/unit/direct-migration.test.ts +++ b/apps/mimic-db/tests/unit/direct-migration.test.ts @@ -4,7 +4,7 @@ import { defineMigrationRegistry, type AnyDirectMigration, } from "@voidhash/mimic-server/migrate"; -import { makeDurableEntityAddress } from "@orbian/sdk/DurableEntity"; +import { makeDurableEntityAddress } from "@voidhash/platform/DurableEntity"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; diff --git a/docs/architecture.md b/docs/architecture.md index 18efd0c4..b83058e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ repository. ```mermaid flowchart TD Community["voidhash Community codebase
MIT SDKs + AGPL services"] - Platform["@orbian/sdk
provider-neutral contracts"] + Platform["@voidhash/platform
provider-neutral contracts"] Node["Community self-host
Node + PostgreSQL + MinIO + optional ClickHouse"] Cloud["Managed Cloud
Cloudflare + PlanetScale adapters"] Private["Private composition
Enterprise + Overwatch + deployment graph"] @@ -27,18 +27,21 @@ flowchart TD - `libraries/` contains MIT SDKs embedded in customer applications. - `apps/backend`, `apps/www`, and `apps/mimic-db` are the AGPL service entry points. -- `@orbian/sdk` defines provider-neutral Effect services and application - primitives for durable objects, queues, workflows, scheduled jobs, key-value +- `@voidhash/platform` defines provider-neutral Effect services and application + primitives for durable entities, queues, workflows, scheduled jobs, key-value storage, object storage, screenshots, and mail. - `packages/core`, `packages/db`, `packages/rpc`, and the remaining service packages own portable application and domain behavior. -- `@orbian/node` implements those contracts for a single Node deployment. - `selfhost/entry` composes the Community application. +- `@voidhash/platform-node` implements those contracts for a single Node + deployment on PostgreSQL. `selfhost/entry` composes the Community application. +- `@voidhash/platform-cluster` implements the same contracts on Effect Cluster + and Effect Workflow, and is the portable default for operators who want + durable execution without a bespoke Postgres engine. -Release lockfiles pin `@orbian/sdk` and `@orbian/node` to immutable Orbian -commit artifacts. Contributors working in adjacent checkouts can switch to the -sibling workspace with `pnpm orbian:source workspace`; maintainers prepare a -standalone release with `pnpm orbian:source `. +Runtime backends are selected per primitive, not per provider: a deployment may +combine, for example, cluster workflows with a Postgres queue driver. Every +adapter is validated against the shared conformance suite in +`@voidhash/platform/conformance`. The publication-boundary check rejects private package scopes, infrastructure directories, Enterprise code, operations-plane code, and incomplete package @@ -58,8 +61,8 @@ path and operational requirements. ## Managed cloud composition -The private repository can deploy the same Orbian application primitives to -Cloudflare through `@orbian/alchemy`, and owns deployment state, environments, +The private repository can deploy the same application primitives to +Cloudflare through Alchemy, and owns deployment state, environments, secrets, and cloud-only integration tests. Product services continue to import provider-neutral interfaces; a zero-baseline seam check rejects new Cloudflare or Alchemy imports from application code. diff --git a/package.json b/package.json index 66e4d978..e2a55891 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "openapi:generate": "node ./scripts/generate-openapi-clients.mjs", "openapi:generate:dev": "pnpm openapi:generate -- localhost:8787", "openapi:generate:prod": "pnpm openapi:generate -- api.voidhash.com", - "orbian:source": "node ./scripts/set-orbian-source.mjs", "db:generate": "node ./scripts/db-generate.mjs", "db:migrate": "node ./scripts/db-migrate-local.mjs", "sync:plugins": "tsx ./scripts/sync-plugins.ts", @@ -54,6 +53,9 @@ "check:selfhost-runtime": "node ./scripts/check-selfhost-runtime-boundary.mjs", "selfhost:smoke": "tsx selfhost/smoke.mts", "selfhost:release-smoke": "tsx selfhost/release-smoke.mts", + "stack:up": "docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --profile analytics --project-directory selfhost up -d --build", + "stack:down": "docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml --profile analytics --project-directory selfhost down", + "test:integration": "node ./scripts/run-local-integration.mjs", "test": "turbo test", "test:purchase-restore": "pnpm --filter @voidhash/paywalls build && pnpm --filter @voidhash/react-native specs && pnpm --filter @voidhash/react-native typecheck && pnpm --filter @voidhash/react-native test && pnpm --filter @voidhash/react-native test:android-purchase-coordinator && pnpm test:android-native-compile && pnpm --filter @voidhash/generated-clients typecheck && pnpm --filter @voidhash/api-contracts typecheck && pnpm --filter @voidhash/backend typecheck && pnpm --filter @voidhash/backend test", "test:android-native-compile": "pnpm --filter @voidhash/react-native-voidhash-example exec expo prebuild --platform android --no-install && examples/react-native-example/android/gradlew -p examples/react-native-example/android :voidhash_react-native:compileDebugKotlin --no-daemon", diff --git a/packages/agent/package.json b/packages/agent/package.json index 99ac1005..b5fc78d9 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -31,12 +31,12 @@ "dependencies": { "@earendil-works/pi-agent-core": "0.80.7", "@earendil-works/pi-ai": "0.80.7", - "@orbian/sdk": "https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526", + "@voidhash/platform": "workspace:*", "effect": "catalog:", "typebox": "1.1.38" }, "devDependencies": { - "@orbian/node": "https://pkg.voidha.sh/orbian-node/fc444bad5db7da1302f6fa0b1a9bd7cadd800526", + "@voidhash/platform-node": "workspace:*", "@voidhash/tsconfig": "workspace:*", "typescript": "catalog:", "vite-plus": "catalog:", diff --git a/packages/agent/src/AgentSessionCore.ts b/packages/agent/src/AgentSessionCore.ts index 413cd150..2b28bdc3 100644 --- a/packages/agent/src/AgentSessionCore.ts +++ b/packages/agent/src/AgentSessionCore.ts @@ -5,7 +5,7 @@ import { type DurableEntityHostShape, type DurableEntitySession, makeDurableEntityAddress, -} from "@orbian/sdk/DurableEntity"; +} from "@voidhash/platform/DurableEntity"; import { Effect, Semaphore } from "effect"; import { diff --git a/packages/agent/src/SessionLog.ts b/packages/agent/src/SessionLog.ts index 7d75f840..2c04f1b6 100644 --- a/packages/agent/src/SessionLog.ts +++ b/packages/agent/src/SessionLog.ts @@ -3,7 +3,7 @@ import type { DurableEntityAddress, DurableEntityContext, DurableEntityHostShape, -} from "@orbian/sdk/DurableEntity"; +} from "@voidhash/platform/DurableEntity"; import { Effect } from "effect"; const LOG_META_KEY = "agent-session/log/meta"; diff --git a/packages/agent/tests/AgentSessionCore.test.ts b/packages/agent/tests/AgentSessionCore.test.ts index 995aeaf8..4164d170 100644 --- a/packages/agent/tests/AgentSessionCore.test.ts +++ b/packages/agent/tests/AgentSessionCore.test.ts @@ -5,8 +5,8 @@ import { type Context as PiContext, type Model, } from "@earendil-works/pi-ai"; -import { makeMemoryDurableEntityHost } from "@orbian/node/MemoryDurableEntity"; -import { makeNodeDurableEntitySession } from "@orbian/node/NodeDurableEntitySession"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-node/MemoryDurableEntity"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-node/NodeDurableEntitySession"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; diff --git a/packages/agent/tests/AgentSessionPg.integration.test.ts b/packages/agent/tests/AgentSessionPg.integration.test.ts index 27d3304b..e7ac9e86 100644 --- a/packages/agent/tests/AgentSessionPg.integration.test.ts +++ b/packages/agent/tests/AgentSessionPg.integration.test.ts @@ -4,12 +4,12 @@ import { type AssistantMessage, type Model, } from "@earendil-works/pi-ai"; -import { DurableEntityHost } from "@orbian/sdk/DurableEntity"; -import { makeNodeDurableEntitySession } from "@orbian/node/NodeDurableEntitySession"; +import { DurableEntityHost } from "@voidhash/platform/DurableEntity"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-node/NodeDurableEntitySession"; import { PgDurableEntityHostLive, type PgDurableEntityConfig, -} from "@orbian/node/DurableEntity"; +} from "@voidhash/platform-node/DurableEntity"; import { Effect, ManagedRuntime, Redacted } from "effect"; import { describe, expect, it } from "vitest"; diff --git a/packages/agent/tests/SessionLog.test.ts b/packages/agent/tests/SessionLog.test.ts index 5e8e4beb..6bcec015 100644 --- a/packages/agent/tests/SessionLog.test.ts +++ b/packages/agent/tests/SessionLog.test.ts @@ -1,4 +1,4 @@ -import { makeMemoryDurableEntityHost } from "@orbian/node/MemoryDurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-node/MemoryDurableEntity"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; diff --git a/packages/agent/tests/workerd-do-probe.ts b/packages/agent/tests/workerd-do-probe.ts index 8a9e7ba9..258b7d65 100644 --- a/packages/agent/tests/workerd-do-probe.ts +++ b/packages/agent/tests/workerd-do-probe.ts @@ -13,7 +13,7 @@ import { import type { DurableEntityHostShape, DurableEntitySession, -} from "@orbian/sdk/DurableEntity"; +} from "@voidhash/platform/DurableEntity"; import { Context, Effect, Semaphore } from "effect"; declare class WebSocketPair { diff --git a/packages/core/package.json b/packages/core/package.json index c524357d..19592ef6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -144,7 +144,8 @@ "typecheck": "tsc --noEmit", "typecheck-go": "tsgo --noEmit", "test": "vp test run -c vitest.unit.mts", - "test:watch": "vp test -c vitest.unit.mts" + "test:watch": "vp test -c vitest.unit.mts", + "test:integration": "vp test run -c vitest.integration.mts" }, "dependencies": { "@distilled.cloud/stripe": "0.30.3", @@ -158,7 +159,7 @@ "@voidhash/lib": "workspace:*", "@voidhash/paywall-build": "workspace:*", "@voidhash/paywall-workspace": "workspace:*", - "@orbian/sdk": "https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526", + "@voidhash/platform": "workspace:*", "@voidhash/rpc": "workspace:*", "@workos-inc/node": "9.2.0", "nanoid": "^5.1.5", diff --git a/packages/core/src/services/analyticsIngest/AnalyticsDispatchService.ts b/packages/core/src/services/analyticsIngest/AnalyticsDispatchService.ts index f14e69b2..2733a3b2 100644 --- a/packages/core/src/services/analyticsIngest/AnalyticsDispatchService.ts +++ b/packages/core/src/services/analyticsIngest/AnalyticsDispatchService.ts @@ -12,7 +12,7 @@ * Tests / non-worker hosts get {@link AnalyticsDispatchService.noop}. */ import { Context, Effect, Layer } from "effect"; -import type { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; +import type { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { type CapturedEventV1Type, diff --git a/packages/core/src/services/analyticsIngest/CaptureIngress.ts b/packages/core/src/services/analyticsIngest/CaptureIngress.ts index dd07e71f..2e439f01 100644 --- a/packages/core/src/services/analyticsIngest/CaptureIngress.ts +++ b/packages/core/src/services/analyticsIngest/CaptureIngress.ts @@ -1,5 +1,5 @@ import { Context, Effect, Layer, Schema } from "effect"; -import type { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; +import type { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import type { CapturedEventV1Type, diff --git a/packages/core/src/services/analyticsIngest/PolicyCounterStore.ts b/packages/core/src/services/analyticsIngest/PolicyCounterStore.ts index 807aa6ca..bb36643b 100644 --- a/packages/core/src/services/analyticsIngest/PolicyCounterStore.ts +++ b/packages/core/src/services/analyticsIngest/PolicyCounterStore.ts @@ -12,7 +12,7 @@ * port to a provider implementation. */ import { Context, Effect, Layer, Schema } from "effect"; -import type { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; +import type { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; export class PolicyStoreError extends Schema.TaggedErrorClass("PolicyStoreError")( "PolicyStoreError", diff --git a/packages/core/src/services/infrastructure/QueueProducer.ts b/packages/core/src/services/infrastructure/QueueProducer.ts index 2c9a5551..01ea82c2 100644 --- a/packages/core/src/services/infrastructure/QueueProducer.ts +++ b/packages/core/src/services/infrastructure/QueueProducer.ts @@ -1,8 +1,8 @@ /** * Compatibility export for the provider-neutral queue contract now owned by - * `@orbian/sdk`. + * `@voidhash/platform`. */ export { QueueProducerError, type QueueProducer, -} from "@orbian/sdk/Queue"; +} from "@voidhash/platform/Queue"; diff --git a/packages/core/src/services/notifications/PushDeliveryDispatch.ts b/packages/core/src/services/notifications/PushDeliveryDispatch.ts index 3c5f12fe..b62bf9b2 100644 --- a/packages/core/src/services/notifications/PushDeliveryDispatch.ts +++ b/packages/core/src/services/notifications/PushDeliveryDispatch.ts @@ -1,5 +1,5 @@ import { Context, Effect, Layer, Schema } from "effect"; -import type { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; +import type { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; /** * Catch-all dispatch error. Wraps the underlying queue `send`/encode failure at diff --git a/packages/core/test/_testing/CoreAuthSession.ts b/packages/core/test/_testing/CoreAuthSession.ts new file mode 100644 index 00000000..43cfdfba --- /dev/null +++ b/packages/core/test/_testing/CoreAuthSession.ts @@ -0,0 +1,70 @@ +import { + type AnyAuthSession, + AuthSession, + type UserSession, +} from "@voidhash/core/domain/auth/Auth"; +import { Effect } from "effect"; + +import { CoreTestFixture } from "./CoreTestFixture"; + +/** + * Default authenticated session for tests: a `user`-method session for the + * seeded {@link CoreTestFixture} principal, holding `organization:all` / + * `project:all` so `checkProjectPermission(fixture.projectId, "project:all")` + * passes. Override fields (e.g. narrower permissions) by passing a custom + * session to {@link CoreAuthSession.authenticate}. + */ +const defaultUserSession = (): UserSession => ({ + cookie: null, + method: "user", + name: `${CoreTestFixture.userName} <${CoreTestFixture.userEmail}>`, + organizations: [ + { + id: CoreTestFixture.organizationId, + logo: null, + name: CoreTestFixture.organizationName, + permissions: ["organization:all"], + slug: CoreTestFixture.organizationSlug, + workosOrganizationId: CoreTestFixture.workosOrganizationId, + }, + ], + person: null, + projects: [ + { + id: CoreTestFixture.projectId, + logo: null, + name: CoreTestFixture.projectName, + organizationId: CoreTestFixture.organizationId, + permissions: ["project:all"], + slug: CoreTestFixture.projectSlug, + }, + ], + user: { + createdAt: new Date(0), + email: CoreTestFixture.userEmail, + emailVerified: true, + id: CoreTestFixture.userId, + image: null, + name: CoreTestFixture.userName, + role: null, + updatedAt: new Date(0), + workosUserId: CoreTestFixture.workosUserId, + }, +}); + +export const CoreAuthSession = { + /** + * Pipeable that provides {@link AuthSession} to a test effect. Call with no + * arguments for the default full-permission fixture user, or pass a custom + * session to exercise other auth methods / permission sets: + * + * ```ts + * effect.pipe(CoreAuthSession.authenticate()) + * effect.pipe(CoreAuthSession.authenticate(mySecretKeySession)) + * ``` + */ + authenticate: + (session: AnyAuthSession = defaultUserSession()) => + (effect: Effect.Effect) => + effect.pipe(Effect.provideService(AuthSession, session)), +}; diff --git a/packages/core/test/_testing/CoreIntegrationTestHarness.ts b/packages/core/test/_testing/CoreIntegrationTestHarness.ts new file mode 100644 index 00000000..be3b3edb --- /dev/null +++ b/packages/core/test/_testing/CoreIntegrationTestHarness.ts @@ -0,0 +1,207 @@ +import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; +import { Workos } from "@voidhash/core/services/auth/Workos"; +import { AuthSession } from "@voidhash/core/domain/auth/Auth"; +import { generateId } from "@voidhash/core/utils"; +import { + AuditLogPort, + AuditLogPortError, + ProjectSchemaCache, + PublicFileStore, + SchemaCacheInvalidationService, +} from "@voidhash/core/services"; +import { AuditLogActorType, Db, auditLogs } from "@voidhash/db"; +import { Effect, Layer, Option } from "effect"; +import { inject, test as vitestTest } from "vitest"; +import type { CoreTestConnections } from "./CoreTestConnections.ts"; + +import type {} from "./provided-context.d.ts"; + +/** + * The common services every core integration test gets for free. The harness + * provides infra (`Db`, `ClickhouseWebClient`, `Workos`) plus the cross-cutting support + * services (`ProjectSchemaCache` stub, database-backed `AuditLogPort`, + * `SchemaCacheInvalidationService`) that most feature-service layers depend on. + * A test still provides its own service-under-test layer (e.g. + * `Effect.provide(PerkService.layer)`); its remaining requirements must fall + * within this set, which the wrapped {@link CoreIntegrationTestHarness} `test` + * enforces via `R extends HarnessServices`. + */ +type HarnessServices = + | Db + | ClickhouseWebClient.ClickhouseWebClient + | Workos + | ProjectSchemaCache + | AuditLogPort + | PublicFileStore + | SchemaCacheInvalidationService; + +/** Default per-test timeout; the heavy deploy already happened in globalSetup. */ +const DEFAULT_TEST_TIMEOUT = 120_000; + +/** + * In-memory {@link ProjectSchemaCache} — a fresh per-test map so cached + * schemas never leak between tests. Mirrors the Durable Object port the + * Cloudflare backend provides at runtime. + */ +const makeProjectSchemaCacheStub = () => { + const store = new Map(); + return { + getByName: (projectId: string) => ({ + get: () => Effect.sync(() => store.get(projectId) ?? null), + set: (schema: unknown) => + Effect.sync(() => { + store.set(projectId, schema); + }), + invalidate: () => + Effect.sync(() => { + store.delete(projectId); + }), + }), + }; +}; + +const ProjectSchemaCacheStubLive = Layer.effect( + ProjectSchemaCache, + Effect.sync(makeProjectSchemaCacheStub), +); + +/** Stable public base for the in-memory {@link PublicFileStore} stub. */ +const PUBLIC_FILE_STORE_BASE_URL = "https://files.test.invalid"; + +/** + * In-memory {@link PublicFileStore} — a fresh per-test map so stored avatars + * never leak between tests. Mirrors the R2 adapter the Cloudflare backend + * provides at runtime; URLs resolve under a stable, obviously-non-routable base. + */ +const makePublicFileStoreStub = () => { + const store = new Map(); + return { + publicBaseUrl: PUBLIC_FILE_STORE_BASE_URL, + publicUrl: (key: string) => `${PUBLIC_FILE_STORE_BASE_URL}/files/${key}`, + putObject: (input: { key: string; body: Uint8Array; contentType: string | undefined }) => + Effect.sync(() => { + store.set(input.key, { body: input.body, contentType: input.contentType ?? null }); + }), + getObject: (key: string) => Effect.sync(() => store.get(key) ?? null), + deleteObject: (key: string) => + Effect.sync(() => { + store.delete(key); + }), + }; +}; + +const PublicFileStoreStubLive = Layer.effect(PublicFileStore, Effect.sync(makePublicFileStoreStub)); + +/** Test-only audit writer that preserves mutation side-effect assertions without an EE dependency. */ +const AuditLogPortTestLive: Layer.Layer = Layer.effect( + AuditLogPort, + Effect.gen(function* () { + const db = yield* Db; + return AuditLogPort.of({ + append: (input) => + Effect.gen(function* () { + const maybeSession = yield* Effect.serviceOption(AuthSession); + const session = Option.isSome(maybeSession) ? maybeSession.value : null; + yield* db.insert(auditLogs).values({ + action: input.action, + actorType: input.actorType ?? AuditLogActorType.User, + actorUserId: session?.user?.id ?? null, + changes: input.changes ?? null, + entityId: input.entityId, + entityType: input.entityType, + id: generateId("auditLog"), + metadata: input.metadata ?? null, + parentEntityId: input.parentEntityId ?? null, + projectId: input.projectId, + }); + }).pipe(Effect.mapError((error) => new AuditLogPortError({ cause: String(error.cause) }))), + }); + }), +); + +/** + * Build the live infra + support layer from the shared + * {@link CoreTestConnections}. Every credential is real (Db/Clickhouse over + * the network, Workos SDK); only the schema cache is a stub. + */ +const makeHarnessLayer = (tc: CoreTestConnections): Layer.Layer => { + const DbLive: Layer.Layer = Db.layer(tc.db); + const ClickhouseLive = ClickhouseWebClient.layer(tc.clickhouse).pipe(Layer.orDie); + const WorkosLive: Layer.Layer = Workos.layer({ + apiKey: Effect.succeed(tc.workos.apiKey), + clientId: Effect.succeed(tc.workos.clientId), + cookieName: Effect.succeed(tc.workos.cookieName), + cookiePassword: Effect.succeed(tc.workos.cookiePassword), + webhookSecret: Effect.succeed(tc.workos.webhookSecret), + }); + + const InfraLayer = Layer.mergeAll( + DbLive, + ClickhouseLive, + WorkosLive, + ProjectSchemaCacheStubLive, + PublicFileStoreStubLive, + ); + + const AuditLogSupportLayer = AuditLogPortTestLive.pipe(Layer.provide(InfraLayer)); + + const SupportLayer = Layer.mergeAll( + AuditLogSupportLayer, + SchemaCacheInvalidationService.layer, + ).pipe(Layer.provide(InfraLayer)); + + return Layer.mergeAll(InfraLayer, SupportLayer); +}; + +const timeoutOf = (options: number | { timeout?: number } | undefined): number => + (typeof options === "number" ? options : options?.timeout) ?? DEFAULT_TEST_TIMEOUT; + +export const CoreIntegrationTestHarness = { + /** + * Returns a lean `test` that injects the common core services into every test + * (and `stack`, the once-per-run deploy output shared via `globalSetup`). + * Tests just `yield*` a service and assert; provide the service-under-test's + * own layer and authenticate via `CoreAuthSession.authenticate()`: + * + * ```ts + * const { test } = CoreIntegrationTestHarness.make(); + * test("…", Effect.gen(function* () { … }) + * .pipe(Effect.provide(PerkService.layer), CoreAuthSession.authenticate())); + * ``` + * + * The environment is provisioned once per run by the active composition's + * `globalSetup` (locally: `test/_testing/globalSetup.ts` over the self-host + * stack) and shared through vitest's `provide`/`inject` channel. + */ + make: () => { + // Resolved lazily inside each test/effect: vitest's injected context is set + // by globalSetup before any file runs, but `inject` must be read at runtime. + const stack = Effect.suspend(() => Effect.succeed(inject("coreStackOutput"))); + + const test = ( + name: string, + eff: Effect.Effect, + options?: number | { timeout?: number }, + ): void => + vitestTest( + name, + async () => { + const testConnections = inject("coreStackOutput")?.testConnections ?? null; + if (testConnections === null) { + throw new Error( + "CoreIntegrationTestHarness: coreStackOutput missing or testConnections is null — globalSetup failed, or the composition refused to expose credentials.", + ); + } + // `R extends HarnessServices` guarantees the layer satisfies every + // requirement, but TS won't reduce `Exclude` to + // `never` for a generic `R`, so assert the discharged type. + await Effect.runPromise( + eff.pipe(Effect.provide(makeHarnessLayer(testConnections))) as Effect.Effect, + ); + }, + timeoutOf(options), + ); + + return { stack, test }; + }, +}; diff --git a/packages/core/test/_testing/CoreTestConnections.ts b/packages/core/test/_testing/CoreTestConnections.ts new file mode 100644 index 00000000..88c95131 --- /dev/null +++ b/packages/core/test/_testing/CoreTestConnections.ts @@ -0,0 +1,72 @@ +/** + * The complete environment contract for the core integration suite. + * + * This is the seam between the open-core tests and whatever composition runs + * them: the Community repo's `globalSetup` derives these values from the local + * self-host stack's environment, while downstream compositions (the managed + * cloud) provision their own infrastructure and inject the same shape. Tests + * never know which composition produced it. + */ +export interface CoreTestConnections { + readonly db: { + readonly host: string; + readonly port: number; + readonly username: string; + readonly password: string; + readonly databaseName: string; + }; + readonly clickhouse: { + readonly url: string; + readonly username: string; + readonly password: string; + readonly database: string; + }; + /** Credential strings only — no core test dials the WorkOS API. */ + readonly workos: { + readonly apiKey: string; + readonly clientId: string; + readonly cookieName: string; + readonly cookiePassword: string; + readonly webhookSecret: string; + }; +} + +/** + * The once-per-run output a composition's `globalSetup` shares with every test + * file. Compositions may inject a structural superset (the managed cloud adds + * deploy artifacts such as URLs); the suite only relies on this shape. + */ +export interface CoreStackOutput { + readonly testConnections: CoreTestConnections | null; +} + +/** + * Builds the contract from environment variables, matching the names the + * self-host stack (`selfhost/.env`) and `scripts/run-local-integration.mjs` + * already use. Defaults target the local docker-compose dev stack. + */ +export const coreTestConnectionsFromEnv = ( + env: Record = process.env, +): CoreTestConnections => ({ + db: { + host: env.DATABASE_HOST ?? "127.0.0.1", + port: Number(env.DATABASE_PORT ?? "5432"), + username: env.DATABASE_USERNAME ?? "voidhash", + password: env.DATABASE_PASSWORD ?? "password", + databaseName: env.DATABASE_NAME ?? "voidhash", + }, + clickhouse: { + url: env.CLICKHOUSE_URL ?? "http://127.0.0.1:8123", + username: env.CLICKHOUSE_USERNAME ?? "voidhash_app", + password: env.CLICKHOUSE_PASSWORD ?? "password", + database: env.CLICKHOUSE_DATABASE ?? "voidhash", + }, + workos: { + apiKey: env.WORKOS_API_KEY ?? "sk_test_selfhost_not_configured", + clientId: env.WORKOS_CLIENT_ID ?? "client_selfhost_not_configured", + cookieName: env.WORKOS_COOKIE_NAME ?? "wos-session", + cookiePassword: + env.WORKOS_COOKIE_PASSWORD ?? "selfhost-development-cookie-password-change-me", + webhookSecret: env.WORKOS_WEBHOOK_SECRET ?? "whsec_selfhost_not_configured", + }, +}); diff --git a/packages/core/test/_testing/CoreTestFixture.ts b/packages/core/test/_testing/CoreTestFixture.ts new file mode 100644 index 00000000..d8271c52 --- /dev/null +++ b/packages/core/test/_testing/CoreTestFixture.ts @@ -0,0 +1,28 @@ +/** + * The single, deterministic test container shared by every core integration + * test (and reused across runs). Seeding upserts these rows once-if-absent so + * all junk entities a test creates live under one org/project/user, and + * {@link CoreAuthSession} builds its session from the same constants so + * permission checks resolve against the seeded project. + * + * IDs are stable, human-readable, and prefixed `it_` so they are easy to spot + * (and bulk-delete) in a shared database. + */ +export const CoreTestFixture = { + userId: "it_user", + userEmail: "integration@voidhash.test", + userName: "Integration Test User", + workosUserId: "it_workos_user", + + organizationId: "it_org", + organizationName: "Integration Test Org", + organizationSlug: "it-org", + workosOrganizationId: "it_workos_org", + + memberId: "it_member", + workosMembershipId: "it_workos_membership", + + projectId: "it_project", + projectName: "Integration Test Project", + projectSlug: "it-project", +} as const; diff --git a/packages/core/test/_testing/CoreTestSeed.ts b/packages/core/test/_testing/CoreTestSeed.ts new file mode 100644 index 00000000..6377b06b --- /dev/null +++ b/packages/core/test/_testing/CoreTestSeed.ts @@ -0,0 +1,142 @@ +import { + Db, + auditLogs, + eq, + inArray, + member, + organization, + paymentProviderConfigurationProducts, + paymentProviderConfigurations, + paywallLocations, + perks, + productPerks, + products, + projects, + user, +} from "@voidhash/db"; +import { Effect } from "effect"; + +import { CoreTestFixture } from "./CoreTestFixture"; + +/** + * Upsert the shared fixture container (user → organization → membership → + * project) once-if-absent. Idempotent via `ON DUPLICATE KEY UPDATE`, so the + * rows are reused across runs and concurrent local runs never collide. Run once + * from `globalSetup`; requires {@link Db}. + */ +export const seedFixture = Effect.gen(function* () { + const db = yield* Db; + + yield* db + .insert(user) + .values({ + createdAt: new Date(), + email: CoreTestFixture.userEmail, + emailVerified: true, + id: CoreTestFixture.userId, + name: CoreTestFixture.userName, + updatedAt: new Date(), + workosUserId: CoreTestFixture.workosUserId, + }) + // Heal the full canonical row on reuse: a prior run's test could have + // updated this shared user (e.g. matched it by the unique email and + // overwritten name/workosUserId), and integration tests assert against + // these exact values. Resetting them keeps every run deterministic. + .onConflictDoUpdate({ + target: user.id, + set: { + email: CoreTestFixture.userEmail, + emailVerified: true, + name: CoreTestFixture.userName, + workosUserId: CoreTestFixture.workosUserId, + }, + }); + + yield* db + .insert(organization) + .values({ + createdAt: new Date(), + id: CoreTestFixture.organizationId, + name: CoreTestFixture.organizationName, + slug: CoreTestFixture.organizationSlug, + workosOrganizationId: CoreTestFixture.workosOrganizationId, + }) + .onConflictDoUpdate({ + target: organization.id, + set: { name: CoreTestFixture.organizationName }, + }); + + yield* db + .insert(member) + .values({ + createdAt: new Date(), + id: CoreTestFixture.memberId, + organizationId: CoreTestFixture.organizationId, + role: "owner", + userId: CoreTestFixture.userId, + workosMembershipId: CoreTestFixture.workosMembershipId, + }) + .onConflictDoUpdate({ target: member.id, set: { role: "owner" } }); + + yield* db + .insert(projects) + .values({ + id: CoreTestFixture.projectId, + name: CoreTestFixture.projectName, + organizationId: CoreTestFixture.organizationId, + slug: CoreTestFixture.projectSlug, + }) + .onConflictDoUpdate({ target: projects.id, set: { name: CoreTestFixture.projectName } }); +}); + +/** + * Best-effort sweep of the entities tests create under the fixture project, + * deepest foreign-key dependents first. The container (user/org/member/project) + * is intentionally retained so it is reused next run. Each delete is wrapped in + * `Effect.ignore` so a missing table / FK quirk never aborts the whole sweep. + * Run once from `globalSetup` teardown; requires {@link Db}. + */ +export const cleanupFixture = Effect.gen(function* () { + const db = yield* Db; + const projectId = CoreTestFixture.projectId; + + const ids = (rows: ReadonlyArray<{ readonly id: string }>) => rows.map((row) => row.id); + const productIds = yield* db + .select({ id: products.id }) + .from(products) + .where(eq(products.projectId, projectId)) + .pipe( + Effect.map(ids), + Effect.catch(() => Effect.succeed([] as string[])), + ); + const perkIds = yield* db + .select({ id: perks.id }) + .from(perks) + .where(eq(perks.projectId, projectId)) + .pipe( + Effect.map(ids), + Effect.catch(() => Effect.succeed([] as string[])), + ); + + if (productIds.length > 0) { + yield* db + .delete(paymentProviderConfigurationProducts) + .where(inArray(paymentProviderConfigurationProducts.productId, productIds)) + .pipe(Effect.ignore); + } + if (perkIds.length > 0) { + yield* db.delete(productPerks).where(inArray(productPerks.perkId, perkIds)).pipe(Effect.ignore); + } + + yield* db.delete(auditLogs).where(eq(auditLogs.projectId, projectId)).pipe(Effect.ignore); + yield* db + .delete(paywallLocations) + .where(eq(paywallLocations.projectId, projectId)) + .pipe(Effect.ignore); + yield* db + .delete(paymentProviderConfigurations) + .where(eq(paymentProviderConfigurations.projectId, projectId)) + .pipe(Effect.ignore); + yield* db.delete(products).where(eq(products.projectId, projectId)).pipe(Effect.ignore); + yield* db.delete(perks).where(eq(perks.projectId, projectId)).pipe(Effect.ignore); +}); diff --git a/packages/core/test/_testing/PurchaseIntegrationTestHarness.ts b/packages/core/test/_testing/PurchaseIntegrationTestHarness.ts new file mode 100644 index 00000000..e0f43b45 --- /dev/null +++ b/packages/core/test/_testing/PurchaseIntegrationTestHarness.ts @@ -0,0 +1,22 @@ +import { Db } from "@voidhash/db"; +import { Effect } from "effect"; +import { test as vitestTest } from "vitest"; + +const databaseConfig = { + databaseName: process.env.DATABASE_NAME ?? "voidhash", + host: process.env.DATABASE_HOST ?? "127.0.0.1", + password: process.env.DATABASE_PASSWORD ?? "password", + port: Number(process.env.DATABASE_PORT ?? "5432"), + username: process.env.DATABASE_USERNAME ?? "voidhash", +}; + +/** Lean Postgres-only harness for purchase-provider integration tests. */ +export const PurchaseIntegrationTestHarness = { + make: () => ({ + test: (name: string, effect: Effect.Effect): void => { + vitestTest(name, () => + Effect.runPromise(effect.pipe(Effect.provide(Db.layer(databaseConfig)))), + ); + }, + }), +}; diff --git a/packages/core/test/_testing/ReactNativePurchaseHarness.ts b/packages/core/test/_testing/ReactNativePurchaseHarness.ts new file mode 100644 index 00000000..0374aebb --- /dev/null +++ b/packages/core/test/_testing/ReactNativePurchaseHarness.ts @@ -0,0 +1,108 @@ +import type { VoidhashCoreClient } from "@voidhash/generated-clients"; +import { Effect } from "effect"; + +import { CacheManager } from "../../../../libraries/react-native/src/core/caching/cache-manager.ts"; +import type { Transaction } from "../../../../libraries/react-native/src/core/entities/transaction.ts"; +import { bindReactNativeSdkClient } from "../../../../libraries/react-native/src/core/networking/api-client.ts"; +import type { PlatformInfo } from "../../../../libraries/react-native/src/core/platform/platform-provider.ts"; +import type { + RuntimeProductDefinition, + RuntimeSchema, +} from "../../../../libraries/react-native/src/core/schema/runtime.ts"; +import { TransactionService } from "../../../../libraries/react-native/src/core/transactions/transaction-service.ts"; +import { + createEffectTestHarness, + createInMemoryCacheAdapter, +} from "../../../../libraries/react-native/tests/helpers/effect-test-harness.ts"; + +export { Transaction } from "../../../../libraries/react-native/src/core/entities/transaction.ts"; +export type { RuntimeSchema } from "../../../../libraries/react-native/src/core/schema/runtime.ts"; + +interface ReactNativePurchaseHarnessOptions { + readonly client: VoidhashCoreClient; + readonly distinctId: string; + readonly onAcknowledge?: ( + transaction: Transaction, + productType: RuntimeProductDefinition["type"] | undefined, + ) => Effect.Effect; + readonly pendingTransactions?: ReadonlyArray; + readonly platform: Partial; + readonly purchaseHistory?: ReadonlyArray; + readonly syncTransactionShouldFailTimes?: number; +} + +/** Runs the real React Native transaction service against a generated HTTP client. */ +export const makeReactNativePurchaseHarness = (options: ReactNativePurchaseHarnessOptions) => { + const acknowledgedTransactions: Transaction[] = []; + const state = { personRefreshAttempts: 0, syncTransactionAttempts: 0 }; + let remainingSyncFailures = options.syncTransactionShouldFailTimes ?? 0; + const cache = createInMemoryCacheAdapter(); + const paymentAdapter = { + acknowledgePurchase: ( + transaction: Transaction, + productType: RuntimeProductDefinition["type"] | undefined, + ) => + Effect.gen(function* () { + acknowledgedTransactions.push(transaction); + if (options.onAcknowledge) { + yield* options.onAcknowledge(transaction, productType); + } + }), + buyProduct: () => Effect.die("Direct native purchase must not run in restore harness"), + endConnection: () => Effect.void, + getPendingTransactions: () => Effect.succeed(options.pendingTransactions ?? []), + getProducts: () => Effect.succeed([]), + getPurchaseHistory: () => Effect.succeed(options.purchaseHistory ?? []), + initConnection: () => Effect.void, + }; + const boundClient = bindReactNativeSdkClient(options.client); + const apiClient = { + ...boundClient, + sdk: { + ...boundClient.sdk, + getPerson: (request: Parameters[0]) => { + state.personRefreshAttempts += 1; + return boundClient.sdk.getPerson(request); + }, + syncTransaction: (request: Parameters[0]) => { + state.syncTransactionAttempts += 1; + if (remainingSyncFailures > 0) { + remainingSyncFailures -= 1; + return Effect.fail(new Error("Simulated SDK transport failure")); + } + return boundClient.sdk.syncTransaction(request); + }, + }, + }; + const harness = createEffectTestHarness({ + apiClient, + cacheAdapter: cache.adapter, + paymentAdapter, + platform: options.platform, + publishableKey: "pk_purchase_integration", + }); + + const initialize = harness.runtime.runPromise( + Effect.gen(function* () { + const cacheManager = yield* CacheManager; + yield* cacheManager.set("distinctId", options.distinctId); + }), + ); + + return { + acknowledgedTransactions, + dispose: () => harness.runtime.dispose(), + initialize, + process: (transaction: Transaction, schema: RuntimeSchema) => + harness.runtime.runPromise( + Effect.flatMap(TransactionService, (service) => + service.processObservedTransaction(transaction, schema), + ), + ), + restore: (schema: RuntimeSchema) => + harness.runtime.runPromise( + Effect.flatMap(TransactionService, (service) => service.restorePurchases(schema)), + ), + state, + }; +}; diff --git a/packages/core/test/_testing/globalSetup.ts b/packages/core/test/_testing/globalSetup.ts new file mode 100644 index 00000000..0acd5f94 --- /dev/null +++ b/packages/core/test/_testing/globalSetup.ts @@ -0,0 +1,45 @@ +import { Db } from "@voidhash/db"; +import * as Effect from "effect/Effect"; + +import { + coreTestConnectionsFromEnv, + type CoreStackOutput, +} from "./CoreTestConnections.ts"; +import { cleanupFixture, seedFixture } from "./CoreTestSeed.ts"; + +/** + * Community composition of the core integration environment: the local + * self-host stack. Connections are derived from the environment (see + * `selfhost/.env.example` and `scripts/run-local-integration.mjs`), the shared + * fixture is seeded, and the contract is shared with every test file via + * vitest's `provide`/`inject`. + * + * Downstream compositions run the exact same suite by supplying their own + * globalSetup: provision infrastructure however they like, then `provide` the + * same `coreStackOutput` shape. Provisioning is the composition's + * responsibility; the suite only consumes the contract. + */ +export default async function setup({ + provide, +}: { + readonly provide: (key: "coreStackOutput", value: CoreStackOutput) => void; +}) { + const testConnections = coreTestConnectionsFromEnv(); + const database = Db.layer(testConnections.db); + + try { + await Effect.runPromise(seedFixture.pipe(Effect.provide(database))); + } catch (cause) { + throw new Error( + "Core integration setup could not seed the fixture. Is the self-host stack running? " + + "Start it with `pnpm stack:up` (see selfhost/README.md) or point DATABASE_* at a migrated database.", + { cause }, + ); + } + + provide("coreStackOutput", { testConnections }); + + return async () => { + await Effect.runPromise(cleanupFixture.pipe(Effect.provide(database))); + }; +} diff --git a/packages/core/test/_testing/provided-context.d.ts b/packages/core/test/_testing/provided-context.d.ts new file mode 100644 index 00000000..2e694b58 --- /dev/null +++ b/packages/core/test/_testing/provided-context.d.ts @@ -0,0 +1,11 @@ +import type { CoreStackOutput } from "./CoreTestConnections.ts"; + +// Typed channel for the once-per-run environment output that a composition's +// `globalSetup` shares with every integration test file via vitest's +// `provide` / `inject`. Kept deliberately narrow: downstream compositions may +// provide richer objects, but the open-core suite depends only on this shape. +declare module "vitest" { + interface ProvidedContext { + coreStackOutput: CoreStackOutput; + } +} diff --git a/packages/core/test/_testing/purchaseGlobalSetup.ts b/packages/core/test/_testing/purchaseGlobalSetup.ts new file mode 100644 index 00000000..fe7c297e --- /dev/null +++ b/packages/core/test/_testing/purchaseGlobalSetup.ts @@ -0,0 +1,19 @@ +import { Db } from "@voidhash/db"; +import { Effect } from "effect"; + +import { cleanupFixture, seedFixture } from "./CoreTestSeed.ts"; + +const databaseConfig = { + databaseName: process.env.DATABASE_NAME ?? "voidhash", + host: process.env.DATABASE_HOST ?? "127.0.0.1", + password: process.env.DATABASE_PASSWORD ?? "password", + port: Number(process.env.DATABASE_PORT ?? "5432"), + username: process.env.DATABASE_USERNAME ?? "voidhash", +}; + +export default async function setup() { + const database = Db.layer(databaseConfig); + await Effect.runPromise(seedFixture.pipe(Effect.provide(database))); + + return () => Effect.runPromise(cleanupFixture.pipe(Effect.provide(database))); +} diff --git a/packages/core/test/integration-harness.integration.test.ts b/packages/core/test/integration-harness.integration.test.ts index 724ff71c..14c577f3 100644 --- a/packages/core/test/integration-harness.integration.test.ts +++ b/packages/core/test/integration-harness.integration.test.ts @@ -5,14 +5,11 @@ import { CoreIntegrationTestHarness } from "@testing/CoreIntegrationTestHarness" const { stack, test } = CoreIntegrationTestHarness.make(); test( - "deploys the backend stack", + "shares the environment contract with the suite", Effect.gen(function* () { const output = yield* stack; - expect(output.backendUrl).toBeDefined(); - expect(output.hyperdriveId).toBeDefined(); - expect(output.mimicDbUrl).toBeDefined(); - expect(output.wwwUrl).toBeDefined(); - // Ephemeral stages expose credentials for the in-process harness layers. + // The composition (self-host locally, richer stacks downstream) must + // expose credentials for the in-process harness layers. expect(output.testConnections).not.toBeNull(); expect(output.testConnections?.db.host).toBeDefined(); expect(output.testConnections?.clickhouse.url).toBeDefined(); diff --git a/packages/core/test/runtime-context-types.ts b/packages/core/test/runtime-context-types.ts index dbc59b52..b8f10e12 100644 --- a/packages/core/test/runtime-context-types.ts +++ b/packages/core/test/runtime-context-types.ts @@ -1,5 +1,5 @@ import type { Effect } from "effect"; -import type { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; +import type { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import type { PolicyCounterStoreShape, diff --git a/packages/core/test/services/analyticsIngest/AnalyticsIngestDlqService.integration.test.ts b/packages/core/test/services/analyticsIngest/AnalyticsIngestDlqService.integration.test.ts index 35482d22..6e0508b2 100644 --- a/packages/core/test/services/analyticsIngest/AnalyticsIngestDlqService.integration.test.ts +++ b/packages/core/test/services/analyticsIngest/AnalyticsIngestDlqService.integration.test.ts @@ -41,7 +41,7 @@ import { CaptureIngress, type PublishableCaptureEvent, } from "@voidhash/core/services/analyticsIngest/CaptureIngress"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { CoreAuthSession } from "@testing/CoreAuthSession"; import { CoreIntegrationTestHarness } from "@testing/CoreIntegrationTestHarness"; diff --git a/packages/core/test/services/analyticsIngest/EventCaptureService.integration.test.ts b/packages/core/test/services/analyticsIngest/EventCaptureService.integration.test.ts index a8d6e35e..23ce7f31 100644 --- a/packages/core/test/services/analyticsIngest/EventCaptureService.integration.test.ts +++ b/packages/core/test/services/analyticsIngest/EventCaptureService.integration.test.ts @@ -50,7 +50,7 @@ import { import { apiKeys, captureProjectPolicies, Db, eq, inArray } from "@voidhash/db"; import { Effect, Layer } from "effect"; import { describe, expect } from "vitest"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { CoreIntegrationTestHarness } from "@testing/CoreIntegrationTestHarness"; import { CoreTestFixture } from "@testing/CoreTestFixture"; diff --git a/packages/core/test/services/purchaseProcessing/PurchaseLedgerWorkerService.integration.test.ts b/packages/core/test/services/purchaseProcessing/PurchaseLedgerWorkerService.integration.test.ts index df2d58ef..d2c7bba6 100644 --- a/packages/core/test/services/purchaseProcessing/PurchaseLedgerWorkerService.integration.test.ts +++ b/packages/core/test/services/purchaseProcessing/PurchaseLedgerWorkerService.integration.test.ts @@ -40,7 +40,7 @@ */ import { Effect, Layer } from "effect"; import { describe, expect, test as vitestTest } from "vitest"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { AnalyticsDispatchService } from "@voidhash/core/services/analyticsIngest/AnalyticsDispatchService"; import { PurchaseLedgerWorkerService } from "@voidhash/core/services/purchaseProcessing/PurchaseLedgerWorkerService"; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index d41a38d0..76f6ed59 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -3,8 +3,13 @@ "include": ["src", "test", "sst-env.d.ts"], "exclude": [ "**/node_modules/**", - "**/*.integration.test.ts", - "test/services/paymentProviders/stripe/stripe-test-support.ts" + // These three reach across package roots (react-native SDK internals and + // the backend's SDK HTTP harness), which rootDir rejects. They are also + // excluded from the shared-fixture integration run; the purchase pipeline + // typechecks them in their home packages. + "test/_testing/ReactNativePurchaseHarness.ts", + "test/services/paymentProviders/appStore/AppStorePaymentProviderService.integration.test.ts", + "test/services/paymentProviders/googlePlay/GooglePlayPaymentProviderService.integration.test.ts" ], "compilerOptions": { "rootDir": ".", diff --git a/packages/core/vitest.integration.mts b/packages/core/vitest.integration.mts new file mode 100644 index 00000000..aff46b9c --- /dev/null +++ b/packages/core/vitest.integration.mts @@ -0,0 +1,32 @@ +import { defineConfig } from "vite-plus"; + +// The integration suite runs against a provisioned environment: locally the +// self-host stack (`pnpm stack:up`), downstream whatever the composition's +// globalSetup provides. Files run sequentially — they share one database and +// one seeded fixture container. +export default defineConfig({ + resolve: { + tsconfigPaths: true, + }, + test: { + exclude: [ + // Purchase-provider flows exercised through the SDK HTTP harness; run + // via the dedicated purchase pipeline, not the shared-fixture suite. + "./test/services/paymentProviders/appStore/AppStorePaymentProviderService.integration.test.ts", + "./test/services/paymentProviders/googlePlay/GooglePlayPaymentProviderService.integration.test.ts", + "./node_modules/**", + ], + include: [ + "./src/**/*.integration.test.ts", + "./test/**/*.integration.test.ts", + ], + globalSetup: ["./test/_testing/globalSetup.ts"], + passWithNoTests: true, + pool: "threads", + fileParallelism: false, + hookTimeout: 300_000, + reporters: ["verbose"], + teardownTimeout: 300_000, + testTimeout: 120_000, + }, +}); diff --git a/packages/platform/LICENSE.md b/packages/platform/LICENSE.md new file mode 100644 index 00000000..19bae358 --- /dev/null +++ b/packages/platform/LICENSE.md @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +1. Source Code. + +The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/platform/package.json b/packages/platform/package.json new file mode 100644 index 00000000..81ecb92e --- /dev/null +++ b/packages/platform/package.json @@ -0,0 +1,40 @@ +{ + "name": "@voidhash/platform", + "version": "0.0.1-alpha.1", + "private": true, + "license": "AGPL-3.0-only", + "repository": { + "type": "git", + "url": "https://github.com/voidhashcom/voidhash", + "directory": "packages/platform" + }, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./CronScheduler": "./src/CronScheduler.ts", + "./EffectWorkflowRunner": "./src/EffectWorkflowRunner.ts", + "./DurableEntity": "./src/DurableEntity.ts", + "./KeyValueStore": "./src/KeyValueStore.ts", + "./Mailer": "./src/Mailer.ts", + "./ObjectStore": "./src/ObjectStore.ts", + "./PlatformRuntime": "./src/PlatformRuntime.ts", + "./Primitive": "./src/Primitive.ts", + "./Queue": "./src/Queue.ts", + "./Screenshot": "./src/Screenshot.ts", + "./Workflow": "./src/Workflow.ts", + "./conformance": "./src/conformance/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "typecheck-go": "tsgo --noEmit" + }, + "devDependencies": { + "@voidhash/tsconfig": "workspace:*", + "effect": "catalog:", + "typescript": "catalog:", + "vite-plus": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" + } +} diff --git a/packages/platform/src/CronScheduler.ts b/packages/platform/src/CronScheduler.ts new file mode 100644 index 00000000..da66686d --- /dev/null +++ b/packages/platform/src/CronScheduler.ts @@ -0,0 +1,89 @@ +import { Context, Effect, Schema } from "effect"; + +import type { PlatformRuntime } from "./PlatformRuntime.ts"; +import type { PrimitiveDefinition } from "./Primitive.ts"; + +/** Stable failure raised by a scheduled-job driver. */ +export class CronSchedulerError extends Schema.TaggedErrorClass( + "CronSchedulerError", +)("CronSchedulerError", { + cause: Schema.String, + jobName: Schema.String, + operation: Schema.String, +}) {} + +/** Metadata passed to a scheduled job invocation. */ +export interface CronJobContext { + readonly scheduledTime: Date; + readonly catchUp: boolean; +} + +/** A provider-neutral scheduled job definition. */ +export interface CronJob { + readonly name: string; + readonly expression: string; + readonly timeZone?: string; + readonly leaseMillis?: number; + readonly run: (context: CronJobContext) => Effect.Effect; +} + +/** Polling policy for a long-running scheduled job. */ +export interface CronRunOptions { + readonly pollIntervalMillis?: number; +} + +/** Provider-neutral persisted cron capabilities. */ +export interface CronSchedulerShape { + /** Claims and runs at most one due schedule slot. */ + readonly tick: ( + job: CronJob, + now?: Date, + ) => Effect.Effect; + /** Polls a job until the enclosing Effect fiber is interrupted. */ + readonly run: ( + job: CronJob, + options?: CronRunOptions, + ) => Effect.Effect; +} + +/** Provider-neutral cron runtime used by application composition roots. */ +export class CronScheduler extends Context.Service()( + "@voidhash/platform/CronScheduler", +) {} + +/** Defines a provider-neutral scheduled job while preserving its requirements. */ +export const defineCron = (job: CronJob): CronJob => job; + +/** + * A scheduled job definition with operations bound to the installed runtime. + * + * Both the single-node scheduler and cloud cron triggers bind the same + * definition, so a schedule is declared exactly once. + */ +export interface CronDefinition + extends CronJob, + PrimitiveDefinition<"cron", Name> { + readonly name: Name; + /** Claims and executes at most one due schedule slot. */ + readonly tick: ( + now?: Date, + ) => Effect.Effect; + /** Runs this schedule until its enclosing fiber is interrupted. */ + readonly start: ( + options?: CronRunOptions, + ) => Effect.Effect; +} + +/** Creates a scheduled job definition without selecting a runtime backend. */ +export const defineScheduledJob = ( + job: CronJob & { readonly name: Name }, +): CronDefinition => { + const definition = { ...job, kind: "cron" as const }; + return { + ...definition, + tick: (now) => + CronScheduler.pipe(Effect.flatMap((scheduler) => scheduler.tick(definition, now))), + start: (options) => + CronScheduler.pipe(Effect.flatMap((scheduler) => scheduler.run(definition, options))), + }; +}; diff --git a/packages/platform/src/DurableEntity.ts b/packages/platform/src/DurableEntity.ts new file mode 100644 index 00000000..9c801455 --- /dev/null +++ b/packages/platform/src/DurableEntity.ts @@ -0,0 +1,77 @@ +import { Context, type Effect } from "effect"; + +/** Stable identity of one durable entity instance. */ +export interface DurableEntityAddress { + readonly type: string; + readonly id: string; +} + +/** Runtime-neutral WebSocket session attached to an entity. */ +export interface DurableEntitySession { + readonly id: string; + readonly send: (message: string | Uint8Array) => Effect.Effect; + readonly close: (code?: number, reason?: string) => Effect.Effect; + readonly getAttachment: Effect.Effect; + readonly setAttachment: (attachment: unknown) => Effect.Effect; +} + +/** Entity-local key-value storage. */ +export interface DurableEntityKeyValue { + readonly get: (key: string) => Effect.Effect; + readonly put: (key: string, value: unknown) => Effect.Effect; + readonly delete: (key: string) => Effect.Effect; +} + +/** Optional embedded SQL store supplied by adapters that support it. */ +export interface DurableEntitySql { + readonly execute: >>( + statement: string, + bindings?: ReadonlyArray, + ) => Effect.Effect>; +} + +/** One replaceable persisted alarm for an entity. */ +export interface DurableEntityAlarm { + readonly get: Effect.Effect; + readonly set: (scheduledTime: number) => Effect.Effect; + readonly delete: Effect.Effect; +} + +/** Live sessions currently attached to an entity instance. */ +export interface DurableEntitySessions { + readonly get: (sessionId: string) => Effect.Effect; + readonly list: Effect.Effect>; + readonly attach: (session: DurableEntitySession) => Effect.Effect; + readonly remove: (sessionId: string) => Effect.Effect; +} + +/** Capabilities visible while one entity operation holds its serialized turn. */ +export interface DurableEntityContext { + readonly address: DurableEntityAddress; + readonly keyValue: DurableEntityKeyValue; + readonly sql?: DurableEntitySql; + readonly alarm: DurableEntityAlarm; + readonly sessions: DurableEntitySessions; +} + +/** First-party host contract for identity-addressed, serialized entity execution. */ +export interface DurableEntityHostShape { + readonly run: ( + address: DurableEntityAddress, + operation: (context: DurableEntityContext) => Effect.Effect, + ) => Effect.Effect; +} + +/** + * Hosts durable entity operations without exposing Cloudflare or single-node + * runtime types to application code. + */ +export class DurableEntityHost extends Context.Service()( + "@voidhash/platform/DurableEntityHost", +) {} + +/** Creates a stable entity address in first-party vocabulary. */ +export const makeDurableEntityAddress = (type: string, id: string): DurableEntityAddress => ({ + type, + id, +}); diff --git a/packages/platform/src/EffectWorkflowRunner.ts b/packages/platform/src/EffectWorkflowRunner.ts new file mode 100644 index 00000000..213fe9b4 --- /dev/null +++ b/packages/platform/src/EffectWorkflowRunner.ts @@ -0,0 +1,227 @@ +import { Cause, Effect, Exit, Layer, Option, Schema } from "effect"; +import { Activity, DurableClock, Workflow, WorkflowEngine } from "effect/unstable/workflow"; + +import { PlatformRuntime } from "./PlatformRuntime.ts"; +import { + type WorkflowDefinition, + type WorkflowExecutionResult, + type WorkflowHandlerContext, + WorkflowRunner, + WorkflowRunnerError, + type WorkflowRunnerShape, + type WorkflowStepOptions, +} from "./Workflow.ts"; + +/** + * Maps the provider-neutral `WorkflowRunner` contract onto Effect's + * `WorkflowEngine`. + * + * This lives beside the contracts rather than in one adapter because every + * backend built on Effect's durable-execution primitives shares the mapping; + * only the engine underneath differs (Postgres tables, cluster entities, an + * in-memory engine for tests). Backends that are not Effect-based implement + * `WorkflowRunner` directly instead. + */ +const runnerError = (workflowName: string, operation: string, cause: unknown) => + new WorkflowRunnerError({ workflowName, operation, cause: String(cause) }); + +const catchRunnerCause = ( + effect: Effect.Effect, + workflowName: string, + operation: string, +): Effect.Effect => + effect.pipe( + Effect.catchCause((cause) => { + const squashed = Cause.squash(cause); + return Effect.fail( + squashed instanceof WorkflowRunnerError + ? squashed + : runnerError(workflowName, operation, Cause.pretty(cause)), + ); + }), + ); + +const toNativeWorkflow = < + const Name extends string, + Payload extends Schema.Struct.Fields, + Success extends Schema.Top, +>( + workflow: WorkflowDefinition, +) => + Workflow.make(workflow.name, { + payload: workflow.payload, + success: workflow.success, + error: WorkflowRunnerError, + idempotencyKey: workflow.idempotencyKey, + }); + +const executionResult = ( + result: Workflow.Result, +): Effect.Effect> => { + if (result._tag === "Suspended") { + return Effect.succeed({ status: "suspended" }); + } + return Effect.succeed( + Exit.match(result.exit, { + onFailure: (cause) => { + if (Cause.hasInterrupts(cause)) { + return { status: "interrupted" as const }; + } + const error = Cause.squash(cause); + return { + status: "failed" as const, + error: error instanceof WorkflowRunnerError ? error : runnerError("unknown", "poll", error), + }; + }, + onSuccess: (value) => ({ status: "succeeded" as const, value }), + }), + ); +}; + +const makeStep = ( + workflowName: string, + options: WorkflowStepOptions, +): Effect.Effect => + Activity.make({ + name: options.name, + success: options.success, + error: WorkflowRunnerError, + execute: catchRunnerCause( + PlatformRuntime.pipe(Effect.andThen(options.execute)), + workflowName, + `step:${options.name}`, + ), + }) as unknown as Effect.Effect; + +const sleepUntil = ( + workflowName: string, + name: string, + scheduledTime: Date, +): Effect.Effect => + catchRunnerCause( + PlatformRuntime.pipe( + Effect.andThen( + Effect.suspend(() => { + const delay = scheduledTime.getTime() - Date.now(); + return delay <= 0 + ? Effect.void + : DurableClock.sleep({ name, duration: delay, inMemoryThreshold: 1 }); + }), + ), + ), + workflowName, + `sleep:${name}`, + ) as unknown as Effect.Effect; + +type AnyWorkflowDefinition = WorkflowDefinition; + +type AnyWorkflowHandler = ( + payload: Schema.Struct.Type, + context: WorkflowHandlerContext, +) => Effect.Effect; + +/** Builds the provider-neutral runner facade over one Effect workflow engine. */ +export const makeEffectWorkflowRunner = ( + engine: WorkflowEngine.WorkflowEngine["Service"], +): WorkflowRunnerShape => + ({ + register: (workflow: AnyWorkflowDefinition, handler: AnyWorkflowHandler) => { + const native = toNativeWorkflow(workflow); + return catchRunnerCause( + engine.register(native, (payload, executionId) => { + const context: WorkflowHandlerContext = { + executionId, + step: (options) => makeStep(workflow.name, options), + sleepUntil: (name, scheduledTime) => sleepUntil(workflow.name, name, scheduledTime), + }; + return catchRunnerCause( + PlatformRuntime.pipe(Effect.andThen(handler(payload, context))), + workflow.name, + "run", + // Activities and durable clocks inside the body resolve the engine + // from context. Engines differ in whether they install themselves + // around a handler, so the runner always does it. + ).pipe(Effect.provideService(WorkflowEngine.WorkflowEngine, engine)); + }), + workflow.name, + "register", + ); + }, + dispatch: ( + workflow: AnyWorkflowDefinition, + payload: Schema.Struct.Type, + ) => { + const native = toNativeWorkflow(workflow); + return catchRunnerCause( + PlatformRuntime.pipe( + Effect.andThen(native.execute(payload, { discard: true })), + Effect.provideService(WorkflowEngine.WorkflowEngine, engine), + ), + workflow.name, + "dispatch", + ); + }, + execute: ( + workflow: AnyWorkflowDefinition, + payload: Schema.Struct.Type, + ) => { + const native = toNativeWorkflow(workflow); + return catchRunnerCause( + PlatformRuntime.pipe( + Effect.andThen(native.execute(payload)), + Effect.provideService(WorkflowEngine.WorkflowEngine, engine), + ), + workflow.name, + "execute", + ); + }, + poll: (workflow: AnyWorkflowDefinition, executionId: string) => { + const native = toNativeWorkflow(workflow); + return catchRunnerCause( + PlatformRuntime.pipe( + Effect.andThen(native.poll(executionId)), + Effect.provideService(WorkflowEngine.WorkflowEngine, engine), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeedNone, + onSome: (result) => executionResult(result).pipe(Effect.map(Option.some)), + }), + ), + ), + workflow.name, + "poll", + ); + }, + resume: (workflow: AnyWorkflowDefinition, executionId: string) => { + const native = toNativeWorkflow(workflow); + return catchRunnerCause( + PlatformRuntime.pipe( + Effect.andThen(native.resume(executionId)), + Effect.provideService(WorkflowEngine.WorkflowEngine, engine), + ), + workflow.name, + "resume", + ); + }, + interrupt: (workflow: AnyWorkflowDefinition, executionId: string) => { + const native = toNativeWorkflow(workflow); + return catchRunnerCause( + PlatformRuntime.pipe( + Effect.andThen(native.interrupt(executionId)), + Effect.provideService(WorkflowEngine.WorkflowEngine, engine), + ), + workflow.name, + "interrupt", + ); + }, + }) as unknown as WorkflowRunnerShape; + +/** + * Provides `WorkflowRunner` from whichever Effect `WorkflowEngine` is in + * context, so an adapter only has to supply its engine layer. + */ +export const EffectWorkflowRunnerLive: Layer.Layer< + WorkflowRunner, + never, + WorkflowEngine.WorkflowEngine +> = Layer.effect(WorkflowRunner, Effect.map(WorkflowEngine.WorkflowEngine, makeEffectWorkflowRunner)); diff --git a/packages/platform/src/KeyValueStore.ts b/packages/platform/src/KeyValueStore.ts new file mode 100644 index 00000000..49cdc92b --- /dev/null +++ b/packages/platform/src/KeyValueStore.ts @@ -0,0 +1,79 @@ +import type { Effect, Option, Schema } from "effect"; +import { Context, Schema as EffectSchema } from "effect"; + +import type { PlatformRuntime } from "./PlatformRuntime.ts"; + +/** Stable failure raised by a platform key-value operation. */ +export class KeyValueStoreError extends EffectSchema.TaggedErrorClass( + "KeyValueStoreError", +)("KeyValueStoreError", { + cause: EffectSchema.String, + namespace: EffectSchema.String, + operation: EffectSchema.String, +}) {} + +/** Optional expiry applied when a key-value entry is written. */ +export interface KeyValuePutOptions { + readonly ttlMillis?: number; +} + +/** A typed key-value entry used by atomic bulk writes. */ +export interface KeyValueEntry { + readonly key: string; + readonly value: A; +} + +/** Provider-neutral typed key-value runtime. */ +export interface KeyValueStoreShape { + /** Reads and schema-decodes one unexpired value. */ + readonly get: ( + namespace: string, + key: string, + schema: Schema.Codec, + ) => Effect.Effect, KeyValueStoreError, PlatformRuntime>; + /** Schema-encodes and writes one value. */ + readonly put: ( + namespace: string, + key: string, + value: A, + schema: Schema.Codec, + options?: KeyValuePutOptions, + ) => Effect.Effect; + /** Atomically schema-encodes and writes a value batch. */ + readonly putMany: ( + namespace: string, + entries: ReadonlyArray>, + schema: Schema.Codec, + options?: KeyValuePutOptions, + ) => Effect.Effect; + /** Returns the subset of keys that currently have unexpired values. */ + readonly existingKeys: ( + namespace: string, + keys: ReadonlyArray, + ) => Effect.Effect, KeyValueStoreError, PlatformRuntime>; + /** Deletes one key. */ + readonly delete: ( + namespace: string, + key: string, + ) => Effect.Effect; + /** Atomically deletes a key batch. */ + readonly deleteMany: ( + namespace: string, + keys: ReadonlyArray, + ) => Effect.Effect; + /** Atomically increments an integer counter and returns its new value. */ + readonly increment: ( + namespace: string, + key: string, + options?: KeyValuePutOptions, + ) => Effect.Effect; + /** Deletes a bounded batch of expired entries and returns the deleted count. */ + readonly pruneExpired: ( + limit: number, + ) => Effect.Effect; +} + +/** Provider-neutral key-value runtime used by application composition roots. */ +export class KeyValueStore extends Context.Service()( + "@voidhash/platform/KeyValueStore", +) {} diff --git a/packages/platform/src/Mailer.ts b/packages/platform/src/Mailer.ts new file mode 100644 index 00000000..cf28e741 --- /dev/null +++ b/packages/platform/src/Mailer.ts @@ -0,0 +1,49 @@ +import type { Effect } from "effect"; +import { Context, Schema } from "effect"; + +import type { PlatformRuntime } from "./PlatformRuntime.ts"; + +/** Stable failure raised by a mail-delivery adapter. */ +export class MailerError extends Schema.TaggedErrorClass("MailerError")( + "MailerError", + { + cause: Schema.String, + operation: Schema.String, + }, +) {} + +/** Structured email mailbox. */ +export interface MailAddress { + readonly address: string; + readonly name?: string; +} + +/** Provider-neutral email message input. */ +export interface MailMessage { + readonly from?: MailAddress; + readonly to: ReadonlyArray; + readonly cc?: ReadonlyArray; + readonly bcc?: ReadonlyArray; + readonly replyTo?: MailAddress; + readonly subject: string; + readonly text?: string; + readonly html?: string; + readonly headers?: Readonly>; +} + +/** Delivery acknowledgement returned by the SMTP server. */ +export interface MailDeliveryResult { + readonly messageId: string; + readonly accepted: ReadonlyArray; + readonly rejected: ReadonlyArray; +} + +/** Provider-neutral mail-delivery capabilities. */ +export interface MailerShape { + readonly send: ( + message: MailMessage, + ) => Effect.Effect; +} + +/** Provider-neutral transactional email delivery service. */ +export class Mailer extends Context.Service()("@voidhash/platform/Mailer") {} diff --git a/packages/platform/src/ObjectStore.ts b/packages/platform/src/ObjectStore.ts new file mode 100644 index 00000000..d79d2e56 --- /dev/null +++ b/packages/platform/src/ObjectStore.ts @@ -0,0 +1,56 @@ +import type { Effect, Option } from "effect"; +import { Context, Schema } from "effect"; + +import type { PlatformRuntime } from "./PlatformRuntime.ts"; + +/** Stable failure raised by an object-storage adapter. */ +export class ObjectStoreError extends Schema.TaggedErrorClass("ObjectStoreError")( + "ObjectStoreError", + { + bucketName: Schema.String, + cause: Schema.String, + key: Schema.String, + operation: Schema.String, + }, +) {} + +/** Raw bytes and metadata returned for a stored object. */ +export interface StoredObject { + readonly body: Uint8Array; + readonly contentType: string | null; + readonly etag: string | null; + readonly size: number; +} + +/** Metadata returned without downloading an object body. */ +export interface StoredObjectHead { + readonly contentType: string | null; + readonly etag: string | null; + readonly size: number; +} + +/** Input for an idempotent object write. */ +export interface PutObjectInput { + readonly key: string; + readonly body: Uint8Array; + readonly contentType?: string; + readonly cacheControl?: string; +} + +/** Provider-neutral object-storage capabilities for one bucket. */ +export interface ObjectStoreShape { + readonly bucketName: string; + readonly put: (input: PutObjectInput) => Effect.Effect; + readonly get: ( + key: string, + ) => Effect.Effect, ObjectStoreError, PlatformRuntime>; + readonly head: ( + key: string, + ) => Effect.Effect, ObjectStoreError, PlatformRuntime>; + readonly delete: (key: string) => Effect.Effect; +} + +/** Provider-neutral object store for a bucket selected by its live layer. */ +export class ObjectStore extends Context.Service()( + "@voidhash/platform/ObjectStore", +) {} diff --git a/packages/platform/src/PlatformRuntime.ts b/packages/platform/src/PlatformRuntime.ts new file mode 100644 index 00000000..b1fd4922 --- /dev/null +++ b/packages/platform/src/PlatformRuntime.ts @@ -0,0 +1,11 @@ +import { Context } from "effect"; + +export type PlatformRuntimeShape = Readonly>; + +/** + * Marks effects that may only execute inside a configured platform runtime. + * Runtime adapters provide this service at their handler boundaries. + */ +export class PlatformRuntime extends Context.Service()( + "@voidhash/platform/PlatformRuntime", +) {} diff --git a/packages/platform/src/Primitive.ts b/packages/platform/src/Primitive.ts new file mode 100644 index 00000000..5aee1de3 --- /dev/null +++ b/packages/platform/src/Primitive.ts @@ -0,0 +1,11 @@ +/** Resource kinds understood by platform runtimes and deployment adapters. */ +export type PrimitiveKind = "cron" | "durable-entity" | "queue" | "queue-consumer" | "workflow"; + +/** Provider-neutral identity shared by every platform primitive definition. */ +export interface PrimitiveDefinition< + Kind extends PrimitiveKind = PrimitiveKind, + Name extends string = string, +> { + readonly kind: Kind; + readonly name: Name; +} diff --git a/packages/platform/src/Queue.ts b/packages/platform/src/Queue.ts new file mode 100644 index 00000000..4c214cac --- /dev/null +++ b/packages/platform/src/Queue.ts @@ -0,0 +1,126 @@ +import type { Schema } from "effect"; +import { Context, Effect, Schema as EffectSchema } from "effect"; + +import type { PlatformRuntime } from "./PlatformRuntime.ts"; +import type { PrimitiveDefinition } from "./Primitive.ts"; + +/** Stable failure raised when a typed queue message cannot be published. */ +export class QueueProducerError extends EffectSchema.TaggedErrorClass( + "QueueProducerError", +)("QueueProducerError", { + cause: EffectSchema.String, + queueName: EffectSchema.String, +}) {} + +/** Provider-neutral typed queue publisher. */ +export interface QueueProducer { + /** Publishes one schema-encoded message. */ + readonly publish: (message: A) => Effect.Effect; + /** Atomically publishes a schema-encoded message batch when the adapter supports it. */ + readonly publishBatch: ( + messages: ReadonlyArray, + ) => Effect.Effect; +} + +/** Stable failure raised by a queue consumer driver. */ +export class QueueConsumerError extends EffectSchema.TaggedErrorClass( + "QueueConsumerError", +)("QueueConsumerError", { + cause: EffectSchema.String, + queueName: EffectSchema.String, +}) {} + +/** Provider-neutral delivery and retry policy for a queue consumer. */ +export interface QueueConsumerOptions { + readonly batchSize?: number; + readonly maxRetries?: number; + readonly retryDelayMillis?: number; + readonly visibilityTimeoutMillis?: number; + readonly pollIntervalMillis?: number; + readonly deadLetterQueue?: string; +} + +/** Runtime queue capabilities shared by cloud and single-node adapters. */ +export interface QueueDriverShape { + /** Creates a typed producer for a logical queue. */ + readonly producer: (queueName: string, schema: Schema.Codec) => QueueProducer; + /** Claims and handles at most one available batch, returning its claimed row count. */ + readonly processBatch: ( + queueName: string, + schema: Schema.Codec, + handleBatch: (messages: ReadonlyArray) => Effect.Effect, + options?: QueueConsumerOptions, + ) => Effect.Effect; + /** Polls and handles batches until the enclosing Effect scope is interrupted. */ + readonly consumeBatch: ( + queueName: string, + schema: Schema.Codec, + handleBatch: (messages: ReadonlyArray) => Effect.Effect, + options?: QueueConsumerOptions, + ) => Effect.Effect; +} + +/** Provider-neutral queue runtime used by application composition roots. */ +export class QueueDriver extends Context.Service()( + "@voidhash/platform/QueueDriver", +) {} + +/** + * A typed queue definition and producer facade. + * + * Definitions are the single canonical declaration of a queue: every runtime + * composition derives its physical resource name from `name`, so logical names + * cannot drift between deployment targets. + */ +export interface QueueDefinition + extends PrimitiveDefinition<"queue", Name> { + readonly schema: Schema.Codec; + /** Publishes one message through the installed queue runtime. */ + readonly publish: ( + message: A, + ) => Effect.Effect; + /** Publishes one atomic batch when supported by the installed runtime. */ + readonly publishBatch: ( + messages: ReadonlyArray, + ) => Effect.Effect; +} + +/** A queue consumer definition executed by the selected runtime. */ +export interface QueueConsumerDefinition + extends PrimitiveDefinition<"queue-consumer", Name> { + readonly queue: QueueDefinition; + readonly options?: QueueConsumerOptions; + readonly handler: (messages: ReadonlyArray) => Effect.Effect; +} + +/** Creates a typed queue definition without selecting a runtime backend. */ +export const defineQueue = ( + name: Name, + schema: Schema.Codec, +): QueueDefinition => ({ + kind: "queue", + name, + schema, + publish: (message) => + QueueDriver.pipe(Effect.flatMap((driver) => driver.producer(name, schema).publish(message))), + publishBatch: (messages) => + QueueDriver.pipe( + Effect.flatMap((driver) => driver.producer(name, schema).publishBatch(messages)), + ), +}); + +/** Defines a typed queue consumer without selecting a runtime backend. */ +export const defineQueueConsumer = ( + name: Name, + options: { + readonly queue: QueueDefinition; + readonly handler: (messages: ReadonlyArray) => Effect.Effect; + readonly options?: QueueConsumerOptions; + }, +): QueueConsumerDefinition => ({ + kind: "queue-consumer", + name, + queue: options.queue, + handler: options.handler, + options: options.options, +}); diff --git a/packages/platform/src/Screenshot.ts b/packages/platform/src/Screenshot.ts new file mode 100644 index 00000000..3b97620e --- /dev/null +++ b/packages/platform/src/Screenshot.ts @@ -0,0 +1,33 @@ +import type { Effect } from "effect"; +import { Context, Schema } from "effect"; + +import type { PlatformRuntime } from "./PlatformRuntime.ts"; + +/** Stable failure raised by an HTML screenshot adapter. */ +export class ScreenshotError extends Schema.TaggedErrorClass("ScreenshotError")( + "ScreenshotError", + { + cause: Schema.String, + operation: Schema.String, + }, +) {} + +/** Viewport and document input for a PNG screenshot. */ +export interface ScreenshotOptions { + readonly html: string; + readonly width: number; + readonly height: number; + readonly deviceScaleFactor: number; +} + +/** Provider-neutral HTML screenshot capabilities. */ +export interface ScreenshotShape { + readonly renderPng: ( + options: ScreenshotOptions, + ) => Effect.Effect; +} + +/** Provider-neutral renderer for self-contained HTML documents. */ +export class Screenshot extends Context.Service()( + "@voidhash/platform/Screenshot", +) {} diff --git a/packages/platform/src/Workflow.ts b/packages/platform/src/Workflow.ts new file mode 100644 index 00000000..c364c855 --- /dev/null +++ b/packages/platform/src/Workflow.ts @@ -0,0 +1,207 @@ +import type { Option, Scope } from "effect"; +import { Context, Effect, Schema } from "effect"; + +import type { PlatformRuntime } from "./PlatformRuntime.ts"; +import type { PrimitiveDefinition } from "./Primitive.ts"; + +/** Stable failure raised by a durable workflow runtime. */ +export class WorkflowRunnerError extends Schema.TaggedErrorClass( + "WorkflowRunnerError", +)("WorkflowRunnerError", { + cause: Schema.String, + operation: Schema.String, + workflowName: Schema.String, +}) {} + +/** Provider-neutral durable workflow definition. */ +export interface WorkflowDefinition< + Name extends string, + Payload extends Schema.Struct.Fields, + Success extends Schema.Top, +> { + readonly name: Name; + readonly payload: Payload; + readonly success: Success; + readonly idempotencyKey: (payload: Schema.Struct.Type) => string; +} + +/** Options for one durable workflow activity. */ +export interface WorkflowStepOptions { + readonly name: string; + readonly success: Success; + readonly execute: Effect.Effect; +} + +/** Durable operations available while a workflow handler is running. */ +export interface WorkflowHandlerContext { + readonly executionId: string; + readonly step: ( + options: WorkflowStepOptions, + ) => Effect.Effect; + readonly sleepUntil: ( + name: string, + scheduledTime: Date, + ) => Effect.Effect; +} + +/** Persisted state returned when polling a workflow execution. */ +export type WorkflowExecutionResult = + | { readonly status: "interrupted" } + | { readonly status: "suspended" } + | { readonly status: "succeeded"; readonly value: A } + | { readonly status: "failed"; readonly error: WorkflowRunnerError }; + +/** Provider-neutral durable workflow capabilities. */ +export interface WorkflowRunnerShape { + readonly register: < + Name extends string, + Payload extends Schema.Struct.Fields, + Success extends Schema.Top, + R, + >( + workflow: WorkflowDefinition, + handler: ( + payload: Schema.Struct.Type, + context: WorkflowHandlerContext, + ) => Effect.Effect, + ) => Effect.Effect; + readonly dispatch: < + Name extends string, + Payload extends Schema.Struct.Fields, + Success extends Schema.Top, + >( + workflow: WorkflowDefinition, + payload: Schema.Struct.Type, + ) => Effect.Effect; + readonly execute: < + Name extends string, + Payload extends Schema.Struct.Fields, + Success extends Schema.Top, + >( + workflow: WorkflowDefinition, + payload: Schema.Struct.Type, + ) => Effect.Effect; + readonly poll: < + Name extends string, + Payload extends Schema.Struct.Fields, + Success extends Schema.Top, + >( + workflow: WorkflowDefinition, + executionId: string, + ) => Effect.Effect< + Option.Option>, + WorkflowRunnerError, + PlatformRuntime + >; + readonly resume: < + Name extends string, + Payload extends Schema.Struct.Fields, + Success extends Schema.Top, + >( + workflow: WorkflowDefinition, + executionId: string, + ) => Effect.Effect; + readonly interrupt: < + Name extends string, + Payload extends Schema.Struct.Fields, + Success extends Schema.Top, + >( + workflow: WorkflowDefinition, + executionId: string, + ) => Effect.Effect; +} + +/** Provider-neutral durable workflow runtime used by composition roots. */ +export class WorkflowRunner extends Context.Service()( + "@voidhash/platform/WorkflowRunner", +) {} + +/** Defines a provider-neutral workflow while preserving schema inference. */ +export const defineWorkflow = < + const Name extends string, + Payload extends Schema.Struct.Fields, + Success extends Schema.Top, +>( + definition: WorkflowDefinition, +): WorkflowDefinition => definition; + +/** + * A workflow definition bundled with its handler, so a single declaration + * carries both the client operations and the body every runtime registers. + */ +export interface WorkflowProgram< + Name extends string, + Payload extends Schema.Struct.Fields, + Success extends Schema.Top, + R, +> extends WorkflowDefinition, + PrimitiveDefinition<"workflow", Name> { + readonly run: ( + payload: Schema.Struct.Type, + context: WorkflowHandlerContext, + ) => Effect.Effect; + /** Registers this workflow handler in the installed runtime. */ + readonly register: Effect.Effect< + void, + WorkflowRunnerError, + Scope.Scope | PlatformRuntime | WorkflowRunner | R + >; + /** Starts the workflow and returns its stable execution ID. */ + readonly dispatch: ( + payload: Schema.Struct.Type, + ) => Effect.Effect; + /** Starts or joins the workflow and awaits successful completion. */ + readonly execute: ( + payload: Schema.Struct.Type, + ) => Effect.Effect; + /** Reads one persisted execution result. */ + readonly poll: ( + executionId: string, + ) => Effect.Effect< + Option.Option>, + WorkflowRunnerError, + PlatformRuntime | WorkflowRunner + >; + /** Resumes a suspended workflow execution. */ + readonly resume: ( + executionId: string, + ) => Effect.Effect; + /** Interrupts a workflow execution. */ + readonly interrupt: ( + executionId: string, + ) => Effect.Effect; +} + +/** Creates a durable workflow program without selecting a runtime backend. */ +export const defineWorkflowProgram = < + const Name extends string, + Payload extends Schema.Struct.Fields, + Success extends Schema.Top, + R, +>( + definition: WorkflowDefinition & { + readonly run: ( + payload: Schema.Struct.Type, + context: WorkflowHandlerContext, + ) => Effect.Effect; + }, +): WorkflowProgram => { + const workflow = defineWorkflow(definition); + return { + ...definition, + kind: "workflow", + register: WorkflowRunner.pipe( + Effect.flatMap((runner) => runner.register(workflow, definition.run)), + ), + dispatch: (payload) => + WorkflowRunner.pipe(Effect.flatMap((runner) => runner.dispatch(workflow, payload))), + execute: (payload) => + WorkflowRunner.pipe(Effect.flatMap((runner) => runner.execute(workflow, payload))), + poll: (executionId) => + WorkflowRunner.pipe(Effect.flatMap((runner) => runner.poll(workflow, executionId))), + resume: (executionId) => + WorkflowRunner.pipe(Effect.flatMap((runner) => runner.resume(workflow, executionId))), + interrupt: (executionId) => + WorkflowRunner.pipe(Effect.flatMap((runner) => runner.interrupt(workflow, executionId))), + }; +}; diff --git a/packages/platform/src/conformance/CronScheduler.ts b/packages/platform/src/conformance/CronScheduler.ts new file mode 100644 index 00000000..43b0f408 --- /dev/null +++ b/packages/platform/src/conformance/CronScheduler.ts @@ -0,0 +1,117 @@ +import { Effect, Layer } from "effect"; +import { describe, expect, it } from "vitest"; + +import { CronScheduler, type CronJobContext } from "../CronScheduler.ts"; +import type { PlatformRuntime } from "../PlatformRuntime.ts"; + +/** Wiring one adapter must supply for the cron conformance suite. */ +export interface CronConformanceOptions { + readonly name: string; + /** Builds an isolated scheduler; called once per test so slots never leak. */ + readonly layer: () => Layer.Layer; +} + +const MINUTELY = "* * * * *"; + +/** + * Behaviour every cron scheduler must exhibit. + * + * `tick` is the unit of the contract: it claims at most one due slot, reports + * whether it ran one, and never replays a slot it has already completed. A + * schedule seen for the first time is armed rather than fired, so deploying a + * new job does not immediately execute it. + */ +export const cronSchedulerConformance = (options: CronConformanceOptions): void => { + const run = ( + effect: Effect.Effect, + ): Promise => + Effect.runPromise( + Effect.scoped(effect.pipe(Effect.provide(options.layer()))) as Effect.Effect, + ); + + const job = (name: string, contexts: Array) => ({ + name, + expression: MINUTELY, + run: (context: CronJobContext) => Effect.sync(() => void contexts.push(context)), + }); + + describe(`${options.name}: cron scheduler conformance`, () => { + it("arms a newly seen schedule without running it", async () => { + const contexts: Array = []; + + const ran = await run( + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + return yield* scheduler.tick(job("conformance-arm", contexts), new Date()); + }), + ); + + expect(ran).toBe(false); + expect(contexts).toEqual([]); + }); + + it("runs a due slot exactly once", async () => { + const contexts: Array = []; + const start = new Date("2026-01-01T00:00:30.000Z"); + // One minute later the armed slot is due; the slot after it is not. + const later = new Date("2026-01-01T00:01:30.000Z"); + + const [armed, due, repeated] = await run( + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + const definition = job("conformance-slot", contexts); + const armed = yield* scheduler.tick(definition, start); + const due = yield* scheduler.tick(definition, later); + const repeated = yield* scheduler.tick(definition, later); + return [armed, due, repeated] as const; + }), + ); + + expect(armed).toBe(false); + expect(due).toBe(true); + // The slot already ran, and the following slot is not yet due. + expect(repeated).toBe(false); + expect(contexts).toHaveLength(1); + }); + + it("marks a late slot as a catch-up run", async () => { + const contexts: Array = []; + const start = new Date("2026-01-01T00:00:30.000Z"); + const late = new Date("2026-01-01T01:00:30.000Z"); + + await run( + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + const definition = job("conformance-catchup", contexts); + yield* scheduler.tick(definition, start); + yield* scheduler.tick(definition, late); + }), + ); + + expect(contexts).toHaveLength(1); + expect(contexts[0]?.catchUp).toBe(true); + // The slot reported is the one that was due, not the current time. + expect(contexts[0]?.scheduledTime.getTime()).toBeLessThan(late.getTime()); + }); + + it("surfaces a failing job as a scheduler error", async () => { + const start = new Date("2026-01-01T00:00:30.000Z"); + const later = new Date("2026-01-01T00:01:30.000Z"); + + const exit = await run( + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + const definition = { + name: "conformance-failure", + expression: MINUTELY, + run: () => Effect.fail("boom"), + }; + yield* scheduler.tick(definition, start); + return yield* Effect.exit(scheduler.tick(definition, later)); + }), + ); + + expect(exit._tag).toBe("Failure"); + }); + }); +}; diff --git a/packages/platform/src/conformance/DurableEntity.ts b/packages/platform/src/conformance/DurableEntity.ts new file mode 100644 index 00000000..1c7426c5 --- /dev/null +++ b/packages/platform/src/conformance/DurableEntity.ts @@ -0,0 +1,160 @@ +import { Effect, Layer } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + DurableEntityHost, + makeDurableEntityAddress, + type DurableEntitySession, +} from "../DurableEntity.ts"; + +/** Wiring one adapter must supply for the durable entity conformance suite. */ +export interface DurableEntityConformanceOptions { + readonly name: string; + /** Builds an isolated host; called once per test so state never leaks. */ + readonly layer: () => Layer.Layer; + /** Adapters without a WebSocket story opt out of the session assertions. */ + readonly supportsSessions?: boolean; +} + +const testSession = (id: string): DurableEntitySession => ({ + id, + send: () => Effect.void, + close: () => Effect.void, + getAttachment: Effect.succeed(undefined), + setAttachment: () => Effect.void, +}); + +/** + * Behaviour every durable entity host must exhibit: operations on one address + * run one at a time, different addresses make progress independently, and + * entity-local state outlives the operation that wrote it. + */ +export const durableEntityHostConformance = ( + options: DurableEntityConformanceOptions, +): void => { + const run = (effect: Effect.Effect): Promise => + Effect.runPromise( + Effect.scoped(effect.pipe(Effect.provide(options.layer()))) as Effect.Effect, + ); + + describe(`${options.name}: durable entity host conformance`, () => { + it("serializes one address while letting different addresses overlap", async () => { + const events: Array = []; + const first = makeDurableEntityAddress("document", "first"); + const second = makeDurableEntityAddress("document", "second"); + + await run( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + yield* Effect.all( + [ + host.run(first, () => + Effect.gen(function* () { + events.push("first:start"); + yield* Effect.sleep("40 millis"); + events.push("first:end"); + }), + ), + host.run(first, () => Effect.sync(() => void events.push("first:next"))), + host.run(second, () => + Effect.gen(function* () { + events.push("second:start"); + yield* Effect.sleep("5 millis"); + events.push("second:end"); + }), + ), + ], + { concurrency: "unbounded" }, + ); + }), + ); + + // The short second-address turn finishes before the long first-address + // turn, and the queued first-address turn only starts once it is done. + expect(events.indexOf("second:end")).toBeLessThan(events.indexOf("first:end")); + expect(events.indexOf("first:end")).toBeLessThan(events.indexOf("first:next")); + }); + + it("retains key-value state across separate turns", async () => { + const address = makeDurableEntityAddress("document", "stateful"); + + const value = await run( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + yield* host.run(address, (entity) => entity.keyValue.put("value", { count: 1 })); + return yield* host.run(address, (entity) => entity.keyValue.get("value")); + }), + ); + + expect(value).toEqual({ count: 1 }); + }); + + it("deletes key-value state", async () => { + const address = makeDurableEntityAddress("document", "deletes"); + + const value = await run( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + yield* host.run(address, (entity) => entity.keyValue.put("value", "present")); + yield* host.run(address, (entity) => entity.keyValue.delete("value")); + return yield* host.run(address, (entity) => entity.keyValue.get("value")); + }), + ); + + expect(value).toBeUndefined(); + }); + + it("round-trips a replaceable alarm", async () => { + const address = makeDurableEntityAddress("document", "alarms"); + + const [set, cleared] = await run( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + yield* host.run(address, (entity) => entity.alarm.set(1234)); + const set = yield* host.run(address, (entity) => entity.alarm.get); + yield* host.run(address, (entity) => entity.alarm.delete); + const cleared = yield* host.run(address, (entity) => entity.alarm.get); + return [set, cleared] as const; + }), + ); + + expect(set).toBe(1234); + expect(cleared).toBeUndefined(); + }); + + it("isolates state between addresses", async () => { + const first = makeDurableEntityAddress("document", "isolated-a"); + const second = makeDurableEntityAddress("document", "isolated-b"); + + const value = await run( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + yield* host.run(first, (entity) => entity.keyValue.put("value", "a")); + return yield* host.run(second, (entity) => entity.keyValue.get("value")); + }), + ); + + expect(value).toBeUndefined(); + }); + + if (options.supportsSessions !== false) { + it("tracks attached sessions", async () => { + const address = makeDurableEntityAddress("document", "sessions"); + + const [attached, remaining] = await run( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + yield* host.run(address, (entity) => entity.sessions.attach(testSession("s-1"))); + const attached = yield* host.run(address, (entity) => entity.sessions.list); + yield* host.run(address, (entity) => entity.sessions.remove("s-1")); + const remaining = yield* host.run(address, (entity) => entity.sessions.list); + return [attached, remaining] as const; + }), + ); + + expect(attached.map((session) => session.id)).toEqual(["s-1"]); + expect(remaining).toEqual([]); + }); + } + }); +}; diff --git a/packages/platform/src/conformance/Queue.ts b/packages/platform/src/conformance/Queue.ts new file mode 100644 index 00000000..594f1b50 --- /dev/null +++ b/packages/platform/src/conformance/Queue.ts @@ -0,0 +1,167 @@ +import { Effect, Layer, Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import type { PlatformRuntime } from "../PlatformRuntime.ts"; +import { QueueDriver } from "../Queue.ts"; + +const Message = Schema.Struct({ id: Schema.String }); + +/** + * Durable stores keep undelivered rows between runs, so queue names are + * per-run to stop one run's leftovers from leaking into the next. + */ +const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +type Message = typeof Message.Type; + +/** Wiring one adapter must supply for the queue conformance suite. */ +export interface QueueConformanceOptions { + /** Adapter name, used in test titles. */ + readonly name: string; + /** Builds an isolated driver; called once per test so state never leaks. */ + readonly layer: () => Layer.Layer; + /** + * Adapters that deliver whole batches can raise this; adapters that deliver + * one message at a time leave it at 1. + */ + readonly maxBatchSize?: number; +} + +/** + * Behaviour every queue driver must exhibit, regardless of backend. + * + * The contract is at-least-once delivery with bounded retries: messages + * survive a failing handler, poison payloads are acknowledged rather than + * redelivered forever, and exhausted messages reach the dead-letter queue when + * one is configured. + */ +export const queueDriverConformance = (options: QueueConformanceOptions): void => { + const run = (effect: Effect.Effect): Promise => + Effect.runPromise(Effect.scoped(effect.pipe(Effect.provide(options.layer()))) as Effect.Effect); + + describe(`${options.name}: queue driver conformance`, () => { + it("delivers a published message to a consumer", async () => { + const seen: Array = []; + + await run( + Effect.gen(function* () { + const driver = yield* QueueDriver; + yield* driver.producer(`conformance-basic-${runId}`, Message).publish({ id: "first" }); + + const claimed = yield* driver.processBatch( + `conformance-basic-${runId}`, + Message, + (messages: ReadonlyArray) => + Effect.sync(() => void seen.push(...messages.map((message) => message.id))), + ); + + expect(claimed).toBeGreaterThan(0); + }), + ); + + expect(seen).toEqual(["first"]); + }); + + it("reports an empty queue instead of blocking", async () => { + const claimed = await run( + Effect.gen(function* () { + const driver = yield* QueueDriver; + return yield* driver.processBatch(`conformance-empty-${runId}`, Message, () => Effect.void, { + pollIntervalMillis: 50, + }); + }), + ); + + expect(claimed).toBe(0); + }); + + it("redelivers a message after the handler fails", async () => { + const attempts: Array = []; + + await run( + Effect.gen(function* () { + const driver = yield* QueueDriver; + yield* driver.producer(`conformance-retry-${runId}`, Message).publish({ id: "retry-me" }); + + const consume = (shouldFail: boolean) => + driver.processBatch( + `conformance-retry-${runId}`, + Message, + (messages: ReadonlyArray) => + Effect.sync(() => void attempts.push(...messages.map((m) => m.id))).pipe( + Effect.andThen(shouldFail ? Effect.fail("boom") : Effect.void), + ), + { maxRetries: 2, pollIntervalMillis: 100, retryDelayMillis: 1 }, + ); + + yield* consume(true); + yield* consume(false); + }), + ); + + expect(attempts).toEqual(["retry-me", "retry-me"]); + }); + + it("dead-letters a message once retries are exhausted", async () => { + const dead: Array = []; + + await run( + Effect.gen(function* () { + const driver = yield* QueueDriver; + yield* driver.producer(`conformance-dlq-${runId}`, Message).publish({ id: "doomed" }); + + // Zero retries: the first failure is terminal. + yield* driver.processBatch(`conformance-dlq-${runId}`, Message, () => Effect.fail("boom"), { + maxRetries: 0, + deadLetterQueue: `conformance-dlq-dead-${runId}`, + pollIntervalMillis: 100, + retryDelayMillis: 1, + }); + + const claimed = yield* driver.processBatch( + `conformance-dlq-dead-${runId}`, + Message, + (messages: ReadonlyArray) => + Effect.sync(() => void dead.push(...messages.map((m) => m.id))), + { pollIntervalMillis: 200 }, + ); + expect(claimed).toBeGreaterThan(0); + }), + ); + + expect(dead).toEqual(["doomed"]); + }); + + it("acknowledges an undecodable payload instead of redelivering it forever", async () => { + const seen: Array = []; + + await run( + Effect.gen(function* () { + const driver = yield* QueueDriver; + // Publish through a schema the consumer cannot decode. + yield* driver + .producer(`conformance-poison-${runId}`, Schema.Struct({ unexpected: Schema.Number })) + .publish({ unexpected: 1 }); + yield* driver.producer(`conformance-poison-${runId}`, Message).publish({ id: "good" }); + + yield* driver.processBatch( + `conformance-poison-${runId}`, + Message, + (messages: ReadonlyArray) => + Effect.sync(() => void seen.push(...messages.map((m) => m.id))), + { pollIntervalMillis: 100 }, + ); + yield* driver.processBatch( + `conformance-poison-${runId}`, + Message, + (messages: ReadonlyArray) => + Effect.sync(() => void seen.push(...messages.map((m) => m.id))), + { pollIntervalMillis: 100 }, + ); + }), + ); + + // The poison payload is dropped; the healthy message still arrives. + expect(seen).toEqual(["good"]); + }); + }); +}; diff --git a/packages/platform/src/conformance/Workflow.ts b/packages/platform/src/conformance/Workflow.ts new file mode 100644 index 00000000..b697054d --- /dev/null +++ b/packages/platform/src/conformance/Workflow.ts @@ -0,0 +1,169 @@ +import { Effect, Layer, Schedule, type Scope, Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import type { PlatformRuntime } from "../PlatformRuntime.ts"; +import { defineWorkflow, WorkflowRunner } from "../Workflow.ts"; + +/** Wiring one adapter must supply for the workflow conformance suite. */ +export interface WorkflowConformanceOptions { + readonly name: string; + /** Builds an isolated runner; called once per test so state never leaks. */ + readonly layer: () => Layer.Layer; +} + +/** + * Durable backends keep completed executions forever, and an execution ID is + * derived from the idempotency key. Without a per-run suffix a second run of + * this suite would join the previous run's finished executions and observe no + * handler side effects. + */ +const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const Greet = defineWorkflow({ + name: "conformance-greet", + payload: { subject: Schema.String }, + success: Schema.String, + idempotencyKey: (payload) => payload.subject, +}); + +/** + * Behaviour every durable workflow runner must exhibit: a registered workflow + * runs to a persisted result, steps inside it are durable units, and the + * execution ID derived from the idempotency key makes a repeated dispatch join + * the original run instead of starting a second one. + */ +export const workflowRunnerConformance = (options: WorkflowConformanceOptions): void => { + // Registering a workflow handler is scoped: the handler stays installed for + // the lifetime of the scope, so each test discharges its own. + const run = ( + effect: Effect.Effect, + ): Promise => + Effect.runPromise( + Effect.scoped(effect.pipe(Effect.provide(options.layer()))) as Effect.Effect, + ); + + /** + * Waits for a dispatched execution to reach a persisted result. + * + * `dispatch` only starts a workflow; backends that run it on another fiber or + * another runner settle it after the call returns, so the suite polls instead + * of assuming the result is already there. + */ + const awaitResult = (executionId: string) => + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + return yield* Effect.retry( + runner + .poll(Greet, executionId) + .pipe( + Effect.flatMap((result) => + result._tag === "Some" && result.value.status !== "suspended" + ? Effect.succeed(result) + : Effect.fail("pending" as const), + ), + ), + { times: 100, schedule: Schedule.spaced("20 millis") }, + ); + }); + + describe(`${options.name}: workflow runner conformance`, () => { + it("executes a registered workflow and returns its result", async () => { + const result = await run( + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + yield* runner.register(Greet, (payload) => Effect.succeed(`hello ${payload.subject}`)); + return yield* runner.execute(Greet, { subject: `world-${runId}` }); + }), + ); + + expect(result).toBe(`hello world-${runId}`); + }); + + it("runs durable steps inside a workflow body", async () => { + const steps: Array = []; + + const result = await run( + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + yield* runner.register(Greet, (payload, context) => + Effect.gen(function* () { + const upper = yield* context.step({ + name: "shout", + success: Schema.String, + execute: Effect.sync(() => { + steps.push("shout"); + return payload.subject.toUpperCase(); + }), + }); + return `hello ${upper}`; + }), + ); + return yield* runner.execute(Greet, { subject: `steps-${runId}` }); + }), + ); + + expect(result).toBe(`hello ${`steps-${runId}`.toUpperCase()}`); + expect(steps).toEqual(["shout"]); + }); + + it("derives a stable execution ID from the idempotency key", async () => { + const started: Array = []; + + const [first, second] = await run( + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + yield* runner.register(Greet, (payload) => + Effect.sync(() => { + started.push(payload.subject); + return `hello ${payload.subject}`; + }), + ); + const first = yield* runner.dispatch(Greet, { subject: `same-${runId}` }); + yield* awaitResult(first); + const second = yield* runner.dispatch(Greet, { subject: `same-${runId}` }); + yield* awaitResult(second); + return [first, second] as const; + }), + ); + + expect(first).toBe(second); + // The second dispatch joins the completed run rather than re-running it. + expect(started).toEqual([`same-${runId}`]); + }); + + it("polls a completed execution", async () => { + const polled = await run( + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + yield* runner.register(Greet, (payload) => Effect.succeed(`hello ${payload.subject}`)); + const executionId = yield* runner.dispatch(Greet, { subject: `polled-${runId}` }); + return yield* awaitResult(executionId); + }), + ); + + expect(polled._tag).toBe("Some"); + if (polled._tag === "Some") { + expect(polled.value.status).toBe("succeeded"); + if (polled.value.status === "succeeded") { + expect(polled.value.value).toBe(`hello polled-${runId}`); + } + } + }); + + it("reports a failing workflow as a failed execution", async () => { + const polled = await run( + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + yield* runner.register(Greet, () => Effect.fail("boom")); + const executionId = yield* runner.dispatch(Greet, { subject: `failing-${runId}` }); + return yield* awaitResult(executionId); + }), + ); + + expect(polled._tag).toBe("Some"); + if (polled._tag === "Some") { + expect(polled.value.status).toBe("failed"); + } + }); + }); +}; diff --git a/packages/platform/src/conformance/index.ts b/packages/platform/src/conformance/index.ts new file mode 100644 index 00000000..806fa08a --- /dev/null +++ b/packages/platform/src/conformance/index.ts @@ -0,0 +1,13 @@ +export { + cronSchedulerConformance, + type CronConformanceOptions, +} from "./CronScheduler.ts"; +export { + durableEntityHostConformance, + type DurableEntityConformanceOptions, +} from "./DurableEntity.ts"; +export { queueDriverConformance, type QueueConformanceOptions } from "./Queue.ts"; +export { + workflowRunnerConformance, + type WorkflowConformanceOptions, +} from "./Workflow.ts"; diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts new file mode 100644 index 00000000..50a5550c --- /dev/null +++ b/packages/platform/src/index.ts @@ -0,0 +1,78 @@ +export { + type CronDefinition, + defineCron, + defineScheduledJob, + type CronJob, + type CronJobContext, + type CronRunOptions, + CronScheduler, + CronSchedulerError, + type CronSchedulerShape, +} from "./CronScheduler.ts"; +export { type PrimitiveDefinition, type PrimitiveKind } from "./Primitive.ts"; +export { + DurableEntityHost, + makeDurableEntityAddress, + type DurableEntityAddress, + type DurableEntityAlarm, + type DurableEntityContext, + type DurableEntityHostShape, + type DurableEntityKeyValue, + type DurableEntitySession, + type DurableEntitySessions, + type DurableEntitySql, +} from "./DurableEntity.ts"; +export { + type KeyValueEntry, + type KeyValuePutOptions, + KeyValueStore, + KeyValueStoreError, + type KeyValueStoreShape, +} from "./KeyValueStore.ts"; +export { + type MailAddress, + type MailDeliveryResult, + Mailer, + MailerError, + type MailerShape, + type MailMessage, +} from "./Mailer.ts"; +export { + ObjectStore, + ObjectStoreError, + type ObjectStoreShape, + type PutObjectInput, + type StoredObject, + type StoredObjectHead, +} from "./ObjectStore.ts"; +export { PlatformRuntime, type PlatformRuntimeShape } from "./PlatformRuntime.ts"; +export { + defineQueue, + defineQueueConsumer, + type QueueConsumerDefinition, + QueueConsumerError, + type QueueConsumerOptions, + type QueueDefinition, + QueueDriver, + type QueueDriverShape, + QueueProducerError, + type QueueProducer, +} from "./Queue.ts"; +export { + Screenshot, + ScreenshotError, + type ScreenshotOptions, + type ScreenshotShape, +} from "./Screenshot.ts"; +export { + defineWorkflow, + defineWorkflowProgram, + type WorkflowProgram, + type WorkflowDefinition, + type WorkflowExecutionResult, + type WorkflowHandlerContext, + WorkflowRunner, + WorkflowRunnerError, + type WorkflowRunnerShape, + type WorkflowStepOptions, +} from "./Workflow.ts"; diff --git a/packages/platform/tsconfig.json b/packages/platform/tsconfig.json new file mode 100644 index 00000000..c530b061 --- /dev/null +++ b/packages/platform/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@voidhash/tsconfig/internal-package-typescript-6.json", + "include": ["src"], + "exclude": ["**/node_modules/**"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fed3c6d2..1d7d97a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@better-auth/api-key': specifier: ^1.6.23 version: 1.6.23 + '@effect-aws/client-s3': + specifier: 2.0.0-beta.4 + version: 2.0.0-beta.4 '@effect/language-service': specifier: ^0.86.2 version: 0.86.6 @@ -60,6 +63,9 @@ catalogs: '@tanstack/zod-adapter': specifier: 1.167.0 version: 1.167.0 + '@types/nodemailer': + specifier: 8.0.1 + version: 8.0.1 '@types/react': specifier: ^19.1.0 version: 19.1.17 @@ -93,6 +99,12 @@ catalogs: lucide-react: specifier: ^0.555.0 version: 0.555.0 + nodemailer: + specifier: 9.0.3 + version: 9.0.3 + playwright-core: + specifier: 1.61.1 + version: 1.61.1 react: specifier: 19.2.7 version: 19.2.7 @@ -311,9 +323,6 @@ importers: '@effect/platform-node': specifier: 'catalog:' version: 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1) - '@orbian/sdk': - specifier: https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526 - version: https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526(effect@4.0.0-beta.100) '@types/bun': specifier: latest version: 1.3.14 @@ -326,6 +335,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.1.9(@types/react@19.1.17) + '@voidhash/platform': + specifier: workspace:* + version: link:../../packages/platform '@voidhash/tsconfig': specifier: workspace:* version: link:../../packages/tsconfig @@ -508,15 +520,15 @@ importers: '@effect/sql-pg': specifier: 'catalog:' version: 4.0.0-beta.100(effect@4.0.0-beta.100) - '@orbian/sdk': - specifier: https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526 - version: https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526(effect@4.0.0-beta.100) '@voidhash/mimic-core': specifier: workspace:* version: link:../../packages/mimic-core '@voidhash/mimic-server': specifier: workspace:* version: link:../../packages/mimic-server + '@voidhash/platform': + specifier: workspace:* + version: link:../../packages/platform effect: specifier: 4.0.0-beta.100 version: 4.0.0-beta.100 @@ -1294,9 +1306,9 @@ importers: '@earendil-works/pi-ai': specifier: 0.80.7 version: 0.80.7(patch_hash=3fbb9897213abf111acd834b26232ea390b59077e07751ef66a6e45bd00a4bd3)(ws@8.21.0)(zod@4.4.3) - '@orbian/sdk': - specifier: https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526 - version: https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526(effect@4.0.0-beta.100) + '@voidhash/platform': + specifier: workspace:* + version: link:../platform effect: specifier: 4.0.0-beta.100 version: 4.0.0-beta.100 @@ -1304,9 +1316,9 @@ importers: specifier: 1.1.38 version: 1.1.38 devDependencies: - '@orbian/node': - specifier: https://pkg.voidha.sh/orbian-node/fc444bad5db7da1302f6fa0b1a9bd7cadd800526 - version: https://pkg.voidha.sh/orbian-node/fc444bad5db7da1302f6fa0b1a9bd7cadd800526(effect@4.0.0-beta.100) + '@voidhash/platform-node': + specifier: workspace:* + version: link:../../selfhost/platform-node '@voidhash/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -1419,9 +1431,6 @@ importers: '@effect/sql-clickhouse': specifier: 'catalog:' version: 4.0.0-beta.100(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.9.1))(effect@4.0.0-beta.100) - '@orbian/sdk': - specifier: https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526 - version: https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526(effect@4.0.0-beta.100) '@paralleldrive/cuid2': specifier: ^2.2.2 version: 2.3.1 @@ -1449,6 +1458,9 @@ importers: '@voidhash/paywall-workspace': specifier: workspace:* version: link:../paywall-workspace + '@voidhash/platform': + specifier: workspace:* + version: link:../platform '@voidhash/rpc': specifier: workspace:* version: link:../rpc @@ -1863,6 +1875,21 @@ importers: specifier: ^3.2.7 version: 3.2.7(@types/debug@4.1.12)(@types/node@20.19.43)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + packages/platform: + devDependencies: + '@voidhash/tsconfig': + specifier: workspace:* + version: link:../tsconfig + effect: + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite-plus: + specifier: 'catalog:' + version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + packages/rpc: dependencies: effect: @@ -1990,12 +2017,6 @@ importers: '@effect/sql-pg': specifier: 'catalog:' version: 4.0.0-beta.100(effect@4.0.0-beta.100) - '@orbian/node': - specifier: https://pkg.voidha.sh/orbian-node/fc444bad5db7da1302f6fa0b1a9bd7cadd800526 - version: https://pkg.voidha.sh/orbian-node/fc444bad5db7da1302f6fa0b1a9bd7cadd800526(effect@4.0.0-beta.100) - '@orbian/sdk': - specifier: https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526 - version: https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526(effect@4.0.0-beta.100) '@voidhash/agent': specifier: workspace:* version: link:../../packages/agent @@ -2035,6 +2056,12 @@ importers: '@voidhash/paywalls': specifier: workspace:* version: link:../../libraries/paywalls + '@voidhash/platform': + specifier: workspace:* + version: link:../../packages/platform + '@voidhash/platform-node': + specifier: workspace:* + version: link:../platform-node effect: specifier: 4.0.0-beta.100 version: 4.0.0-beta.100 @@ -2079,6 +2106,68 @@ importers: specifier: 'catalog:' version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + selfhost/platform-cluster: + dependencies: + '@voidhash/platform': + specifier: workspace:* + version: link:../../packages/platform + effect: + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 + devDependencies: + '@effect/sql-pg': + specifier: 'catalog:' + version: 4.0.0-beta.100(effect@4.0.0-beta.100) + '@types/node': + specifier: ^24.0.12 + version: 24.10.4 + '@voidhash/tsconfig': + specifier: workspace:* + version: link:../../packages/tsconfig + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite-plus: + specifier: 'catalog:' + version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + + selfhost/platform-node: + dependencies: + '@effect-aws/client-s3': + specifier: 'catalog:' + version: 2.0.0-beta.4(effect@4.0.0-beta.100) + '@effect/sql-pg': + specifier: 'catalog:' + version: 4.0.0-beta.100(effect@4.0.0-beta.100) + '@voidhash/platform': + specifier: workspace:* + version: link:../../packages/platform + effect: + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 + nodemailer: + specifier: 'catalog:' + version: 9.0.3 + playwright-core: + specifier: 'catalog:' + version: 1.61.1 + devDependencies: + '@types/node': + specifier: ^24.0.12 + version: 24.10.4 + '@types/nodemailer': + specifier: 'catalog:' + version: 8.0.1 + '@voidhash/tsconfig': + specifier: workspace:* + version: link:../../packages/tsconfig + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite-plus: + specifier: 'catalog:' + version: 0.1.24(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vite@7.3.5(@types/node@24.10.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + packages: '@0no-co/graphql.web@1.2.0': @@ -3642,11 +3731,6 @@ packages: peerDependencies: effect: 4.0.0-beta.100 - '@effect/sql-pg@4.0.0-beta.84': - resolution: {integrity: sha512-DPEwPlbF4ECwQIMpGLKORB+Dxvvv8Hf9yUGrY3QtorT2U9xyzFAi6NAhU0HGox+WfSvnBvfFGK2Z9Y3UBEQOnw==} - peerDependencies: - effect: 4.0.0-beta.100 - '@effect/vitest@4.0.0-beta.100': resolution: {integrity: sha512-WoxrzPuxc+4QXb+7D1j8PwaBb9zVxHM2yw0sDz7fXij1Sx7X7T6LkYJ7m2xzWbfskIXxIdXVA+NnDSF8qAOGsw==} peerDependencies: @@ -5073,30 +5157,6 @@ packages: resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} - '@orbian/core@https://pkg.voidha.sh/orbian-core/fc444ba': - resolution: {tarball: https://pkg.voidha.sh/orbian-core/fc444ba} - version: 0.0.0-gfc444ba - peerDependencies: - effect: 4.0.0-beta.100 - - '@orbian/node@https://pkg.voidha.sh/orbian-node/fc444bad5db7da1302f6fa0b1a9bd7cadd800526': - resolution: {tarball: https://pkg.voidha.sh/orbian-node/fc444bad5db7da1302f6fa0b1a9bd7cadd800526} - version: 0.0.0-gfc444ba - peerDependencies: - effect: 4.0.0-beta.100 - - '@orbian/sdk@https://pkg.voidha.sh/orbian-sdk/fc444ba': - resolution: {tarball: https://pkg.voidha.sh/orbian-sdk/fc444ba} - version: 0.0.0-gfc444ba - peerDependencies: - effect: 4.0.0-beta.100 - - '@orbian/sdk@https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526': - resolution: {tarball: https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526} - version: 0.0.0-gfc444ba - peerDependencies: - effect: 4.0.0-beta.100 - '@oxc-project/runtime@0.133.0': resolution: {integrity: sha512-PkvjA1Lq5++V5S1E6Patr92ZVcieE6EalDr1VJTqv4BnjZdOUC4W3p8k1wMXSd5/2aFP4b/A6N5sg2Bkzcr9vQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8052,6 +8112,9 @@ packages: '@types/node@24.10.4': resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/offscreencanvas@2019.7.3': resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} @@ -13429,9 +13492,6 @@ packages: pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} - pg-connection-string@2.12.0: - resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} - pg-connection-string@2.14.0: resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} @@ -18172,17 +18232,6 @@ snapshots: transitivePeerDependencies: - pg-native - '@effect/sql-pg@4.0.0-beta.84(effect@4.0.0-beta.100)': - dependencies: - effect: 4.0.0-beta.100 - pg: 8.22.0 - pg-connection-string: 2.12.0 - pg-cursor: 2.21.0(pg@8.22.0) - pg-pool: 3.14.0(pg@8.22.0) - pg-types: 4.1.0 - transitivePeerDependencies: - - pg-native - '@effect/vitest@4.0.0-beta.100(@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(yaml@2.8.3))(effect@4.0.0-beta.100)': dependencies: effect: 4.0.0-beta.100 @@ -19800,31 +19849,6 @@ snapshots: '@orama/orama@3.1.18': {} - '@orbian/core@https://pkg.voidha.sh/orbian-core/fc444ba(effect@4.0.0-beta.100)': - dependencies: - '@orbian/sdk': https://pkg.voidha.sh/orbian-sdk/fc444ba(effect@4.0.0-beta.100) - effect: 4.0.0-beta.100 - - '@orbian/node@https://pkg.voidha.sh/orbian-node/fc444bad5db7da1302f6fa0b1a9bd7cadd800526(effect@4.0.0-beta.100)': - dependencies: - '@effect-aws/client-s3': 2.0.0-beta.4(effect@4.0.0-beta.100) - '@effect/sql-pg': 4.0.0-beta.84(effect@4.0.0-beta.100) - '@orbian/core': https://pkg.voidha.sh/orbian-core/fc444ba(effect@4.0.0-beta.100) - '@orbian/sdk': https://pkg.voidha.sh/orbian-sdk/fc444ba(effect@4.0.0-beta.100) - effect: 4.0.0-beta.100 - nodemailer: 9.0.3 - playwright-core: 1.61.1 - transitivePeerDependencies: - - pg-native - - '@orbian/sdk@https://pkg.voidha.sh/orbian-sdk/fc444ba(effect@4.0.0-beta.100)': - dependencies: - effect: 4.0.0-beta.100 - - '@orbian/sdk@https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526(effect@4.0.0-beta.100)': - dependencies: - effect: 4.0.0-beta.100 - '@oxc-project/runtime@0.133.0': {} '@oxc-project/types@0.103.0': {} @@ -23784,6 +23808,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 24.10.4 + '@types/offscreencanvas@2019.7.3': {} '@types/pg@8.20.0': @@ -31341,8 +31369,6 @@ snapshots: pg-cloudflare@1.4.0: optional: true - pg-connection-string@2.12.0: {} - pg-connection-string@2.14.0: {} pg-cursor@2.21.0(pg@8.22.0): diff --git a/scripts/check-selfhost-runtime-boundary.mjs b/scripts/check-selfhost-runtime-boundary.mjs index dc728985..e4430548 100644 --- a/scripts/check-selfhost-runtime-boundary.mjs +++ b/scripts/check-selfhost-runtime-boundary.mjs @@ -9,15 +9,20 @@ const forbiddenPackages = new Set([ "miniflare", "workerd", "wrangler", - "@orbian/alchemy", - "@orbian/cloudflare", "@distilled.cloud/cloudflare", "@distilled.cloud/cloudflare-rolldown-plugin", "@distilled.cloud/cloudflare-runtime", "@distilled.cloud/cloudflare-vite-plugin", ]); -const isForbidden = (name) => forbiddenPackages.has(name) || name.startsWith("@cloudflare/"); +// Types-only packages carry no runtime cloud coupling. `@cloudflare/workers-types` +// reaches the prod tree as an optional peer of better-auth's kysely adapter and +// contains nothing but declaration files. +const allowedTypeOnlyPackages = new Set(["@cloudflare/workers-types"]); + +const isForbidden = (name) => + !allowedTypeOnlyPackages.has(name) && + (forbiddenPackages.has(name) || name.startsWith("@cloudflare/")); const readPackage = (packageDirectory, installed) => { const manifestPath = path.join(packageDirectory, "package.json"); diff --git a/scripts/db-migrate-local.mjs b/scripts/db-migrate-local.mjs index 27fbe188..7f8ce247 100644 --- a/scripts/db-migrate-local.mjs +++ b/scripts/db-migrate-local.mjs @@ -23,6 +23,11 @@ // Connection is read from env with local-dev defaults: // DATABASE_HOST=127.0.0.1 DATABASE_PORT=5432 DATABASE_NAME=voidhash // DATABASE_USERNAME=voidhash DATABASE_PASSWORD=password +// Each `DATABASE_DIRECT_*` override wins over its `DATABASE_*` counterpart, for +// the same reason `getSelfhostMigrationDatabaseConfig` exists: when the app is +// pointed at a sandboxed or proxied endpoint (a connection broker, a +// Hyperdrive-style local socket), that hostname resolves only inside the +// runtime serving requests, while this script needs a real TCP socket. // The Docker image (docker-compose.yml) creates `voidhash` as a superuser that // owns the `voidhash` database, so the app user has full DDL locally. @@ -50,12 +55,15 @@ const isLocalHost = (host) => { return LOCAL_HOSTS.has(h) || h.endsWith(".localhost"); }; +const direct = (name, fallback) => + process.env[`DATABASE_DIRECT_${name}`] ?? process.env[`DATABASE_${name}`] ?? fallback; + const config = { - host: process.env.DATABASE_HOST ?? "127.0.0.1", - port: Number.parseInt(process.env.DATABASE_PORT ?? "5432", 10), - database: process.env.DATABASE_NAME ?? "voidhash", - user: process.env.DATABASE_USERNAME ?? "voidhash", - password: process.env.DATABASE_PASSWORD ?? "password", + host: direct("HOST", "127.0.0.1"), + port: Number.parseInt(direct("PORT", "5432"), 10), + database: direct("NAME", "voidhash"), + user: direct("USERNAME", "voidhash"), + password: direct("PASSWORD", "password"), }; const force = process.argv.includes("--force"); diff --git a/scripts/run-local-integration.mjs b/scripts/run-local-integration.mjs new file mode 100644 index 00000000..f566db7f --- /dev/null +++ b/scripts/run-local-integration.mjs @@ -0,0 +1,152 @@ +// Runs every integration-capable suite against the local self-host stack. +// +// node scripts/run-local-integration.mjs [suite ...] +// +// Reads `selfhost/.env` (falling back to `selfhost/.env.example` defaults), +// derives host-side connection settings from the stack's values (container +// hostnames become 127.0.0.1 plus the published port), enables every suite's +// opt-in flag, and runs the suites sequentially. Sequential matters: the +// suites share one Postgres and one ClickHouse, and parallel runs would race +// on schema setup. +// +// Prerequisite: +// docker compose -f selfhost/docker-compose.yml -f selfhost/docker-compose.dev.yml \ +// --profile analytics up -d --build +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const parseEnvFile = (filePath) => { + const values = {}; + if (!fs.existsSync(filePath)) return values; + for (const line of fs.readFileSync(filePath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const separator = trimmed.indexOf("="); + if (separator === -1) continue; + values[trimmed.slice(0, separator)] = trimmed.slice(separator + 1); + } + return values; +}; + +const stackEnv = parseEnvFile(path.join(repoRoot, "selfhost", ".env")); +const value = (key, fallback) => process.env[key] ?? stackEnv[key] ?? fallback; + +const databasePort = value("DATABASE_HOST_PORT", "5432"); +const databaseUsername = value("DATABASE_USERNAME", "voidhash"); +const databasePassword = value("DATABASE_PASSWORD", "password"); +const databaseName = value("DATABASE_NAME", "voidhash"); +const clickhousePort = value("CLICKHOUSE_HTTP_PORT", "8123"); +const minioPort = value("MINIO_API_PORT", "9000"); +const mailpitSmtpPort = value("MAILPIT_SMTP_PORT", "1025"); +const mailpitUiPort = value("MAILPIT_UI_PORT", "8025"); +const compilerPort = value("COMPILER_HOST_PORT", "5002"); + +const testEnv = { + ...process.env, + + SELFHOST_MODE: "local-evaluation", + DATABASE_HOST: "127.0.0.1", + DATABASE_PORT: databasePort, + DATABASE_USERNAME: databaseUsername, + DATABASE_PASSWORD: databasePassword, + DATABASE_NAME: databaseName, + DATABASE_SSL: "false", + + // The stack's CLICKHOUSE_URL names the compose-internal hostname; tests run + // on the host and reach the published port instead. + CLICKHOUSE_URL: `http://127.0.0.1:${clickhousePort}`, + CLICKHOUSE_DATABASE: value("CLICKHOUSE_DATABASE", "voidhash"), + CLICKHOUSE_ADMIN_USERNAME: value("CLICKHOUSE_ADMIN_USERNAME", "voidhash_admin"), + CLICKHOUSE_ADMIN_PASSWORD: value("CLICKHOUSE_ADMIN_PASSWORD", "password"), + CLICKHOUSE_USERNAME: value("CLICKHOUSE_USERNAME", "voidhash_app"), + CLICKHOUSE_PASSWORD: value("CLICKHOUSE_PASSWORD", "password"), + CLICKHOUSE_RO_USERNAME: value("CLICKHOUSE_RO_USERNAME", "voidhash_ro"), + CLICKHOUSE_RO_PASSWORD: value("CLICKHOUSE_RO_PASSWORD", "password"), + CLICKHOUSE_ANALYTICS_QUERY_USERNAME: value("CLICKHOUSE_ANALYTICS_QUERY_USERNAME", "voidhash_query"), + CLICKHOUSE_ANALYTICS_QUERY_PASSWORD: value("CLICKHOUSE_ANALYTICS_QUERY_PASSWORD", "password"), + + ROOT_USERNAME: value("MIMIC_ROOT_USERNAME", "root"), + ROOT_PASSWORD: value("MIMIC_ROOT_PASSWORD", "password"), + + SELFHOST_COMPILER_URL: `http://127.0.0.1:${compilerPort}`, + + PLATFORM_NODE_PG_HOST: "127.0.0.1", + PLATFORM_NODE_PG_PORT: databasePort, + PLATFORM_NODE_PG_DATABASE: databaseName, + PLATFORM_NODE_PG_USERNAME: databaseUsername, + PLATFORM_NODE_PG_PASSWORD: databasePassword, + PLATFORM_CLUSTER_PG_HOST: "127.0.0.1", + PLATFORM_CLUSTER_PG_PORT: databasePort, + PLATFORM_CLUSTER_PG_DATABASE: databaseName, + PLATFORM_CLUSTER_PG_USERNAME: databaseUsername, + PLATFORM_CLUSTER_PG_PASSWORD: databasePassword, + + PLATFORM_NODE_S3_ENDPOINT: `http://127.0.0.1:${minioPort}`, + PLATFORM_NODE_S3_BUCKET: value("S3_PUBLIC_BUCKET", "voidhash-public"), + PLATFORM_NODE_S3_REGION: value("S3_REGION", "us-east-1"), + PLATFORM_NODE_S3_ACCESS_KEY_ID: value("S3_ACCESS_KEY_ID", "voidhash"), + PLATFORM_NODE_S3_SECRET_ACCESS_KEY: value("S3_SECRET_ACCESS_KEY", "password"), + + PLATFORM_NODE_SMTP_HOST: "127.0.0.1", + PLATFORM_NODE_SMTP_PORT: mailpitSmtpPort, + PLATFORM_NODE_MAILPIT_API: `http://127.0.0.1:${mailpitUiPort}`, + + PLATFORM_NODE_CHROMIUM_EXECUTABLE_PATH: value( + "PLATFORM_NODE_CHROMIUM_EXECUTABLE_PATH", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ), + + SELFHOST_PG_TEST: "1", + SELFHOST_CLICKHOUSE_TEST: "1", + PLATFORM_NODE_PG_TEST: "1", + PLATFORM_NODE_S3_TEST: "1", + PLATFORM_NODE_SMTP_TEST: "1", + PLATFORM_NODE_CHROMIUM_TEST: "1", + PLATFORM_CLUSTER_PG_TEST: "1", + DB_MIGRATIONS_TEST: "1", +}; + +const suites = [ + { name: "platform-node", directory: "selfhost/platform-node" }, + { name: "platform-cluster", directory: "selfhost/platform-cluster" }, + { name: "selfhost-entry", directory: "selfhost/entry" }, + { name: "core", directory: "packages/core", config: "vitest.integration.mts" }, + { name: "backend-smoke", directory: "apps/backend", config: "vitest.integration.mts" }, + { name: "agent", directory: "packages/agent" }, + { name: "db", directory: "packages/db" }, + { name: "mimic-db", directory: "apps/mimic-db" }, +]; + +const requested = process.argv.slice(2); +const selected = requested.length + ? suites.filter((suite) => requested.includes(suite.name)) + : suites; +if (requested.length && selected.length !== requested.length) { + const known = new Set(suites.map((suite) => suite.name)); + const unknown = requested.filter((name) => !known.has(name)); + console.error(`Unknown suite(s): ${unknown.join(", ")}`); + console.error(`Known suites: ${suites.map((suite) => suite.name).join(", ")}`); + process.exit(1); +} + +const vp = path.join(repoRoot, "node_modules", ".bin", "vp"); +const failures = []; +for (const suite of selected) { + console.log(`\n━━━ ${suite.name} (${suite.directory}) ━━━`); + const result = spawnSync(vp, ["test", "run", "-c", suite.config ?? "vitest.mts"], { + cwd: path.join(repoRoot, suite.directory), + env: testEnv, + stdio: "inherit", + }); + if (result.status !== 0) failures.push(suite.name); +} + +if (failures.length > 0) { + console.error(`\nFailed suites: ${failures.join(", ")}`); + process.exit(1); +} +console.log("\nAll integration suites passed."); diff --git a/scripts/set-orbian-source.mjs b/scripts/set-orbian-source.mjs deleted file mode 100644 index addb1b53..00000000 --- a/scripts/set-orbian-source.mjs +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Switches Community packages between a sibling Orbian workspace for local - * development and immutable pr-package artifacts for standalone distribution. - */ - -import { execFileSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const workspaceFile = path.join(repositoryRoot, "pnpm-workspace.yaml"); -const lockFile = path.join(repositoryRoot, "pnpm-lock.yaml"); -const packageOrigin = (process.env.ORBIAN_PACKAGE_ORIGIN ?? "https://pkg.voidha.sh").replace( - /\/$/, - "", -); -const packageProjects = new Map([ - ["@orbian/core", "orbian-core"], - ["@orbian/node", "orbian-node"], - ["@orbian/sdk", "orbian-sdk"], -]); -const workspacePackages = [ - "../orbian/packages/core", - "../orbian/packages/node", - "../orbian/packages/sdk", -]; - -const mode = process.argv[2]; -if (mode !== "workspace" && !/^[0-9a-f]{40}$/.test(mode ?? "")) { - console.error("Usage: pnpm orbian:source "); - process.exit(1); -} - -const packageFiles = [path.join(repositoryRoot, "package.json")]; -for (const group of ["apps", "packages", "libraries", "examples", "selfhost"]) { - const groupDirectory = path.join(repositoryRoot, group); - for (const entry of fs.readdirSync(groupDirectory, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const packageFile = path.join(groupDirectory, entry.name, "package.json"); - if (fs.existsSync(packageFile)) packageFiles.push(packageFile); - } -} - -const packageUrl = (project) => `${packageOrigin}/${project}/${mode}`; - -if (mode !== "workspace") { - for (const project of packageProjects.values()) { - const response = await fetch(packageUrl(project)); - if (!response.ok) { - throw new Error(`Orbian package ${project}@${mode} is unavailable: ${response.status}`); - } - await response.body?.cancel(); - } -} else { - for (const workspacePackage of workspacePackages) { - if (!fs.existsSync(path.resolve(repositoryRoot, workspacePackage, "package.json"))) { - throw new Error(`Orbian workspace package is unavailable: ${workspacePackage}`); - } - } -} - -const mutableFiles = [...packageFiles, workspaceFile, lockFile]; -const originals = new Map( - mutableFiles.filter(fs.existsSync).map((file) => [file, fs.readFileSync(file, "utf8")]), -); - -try { - for (const packageFile of packageFiles) { - const raw = fs.readFileSync(packageFile, "utf8"); - const manifest = JSON.parse(raw); - let changed = false; - for (const field of [ - "dependencies", - "devDependencies", - "optionalDependencies", - "peerDependencies", - ]) { - for (const [packageName, project] of packageProjects) { - if (!(packageName in (manifest[field] ?? {}))) continue; - manifest[field][packageName] = mode === "workspace" ? "workspace:*" : packageUrl(project); - changed = true; - } - } - if (changed) { - const indent = raw.match(/\n([ \t]+)"/)?.[1] ?? " "; - fs.writeFileSync(packageFile, `${JSON.stringify(manifest, null, indent)}\n`); - } - } - - const workspaceLines = fs - .readFileSync(workspaceFile, "utf8") - .split("\n") - .filter((line) => !workspacePackages.includes(line.trim().replace(/^- /, ""))); - if (mode === "workspace") { - const packagesIndex = workspaceLines.findIndex((line) => line.trim() === "packages:"); - workspaceLines.splice( - packagesIndex + 1, - 0, - ...workspacePackages.map((workspacePackage) => ` - ${workspacePackage}`), - ); - } - fs.writeFileSync(workspaceFile, workspaceLines.join("\n")); - - execFileSync( - "corepack", - [ - "pnpm@11.1.3", - "install", - "--lockfile-only", - "--ignore-scripts", - "--config.block-exotic-subdeps=false", - ], - { cwd: repositoryRoot, stdio: "inherit" }, - ); - - const remainingWorkspaceReferences = packageFiles.flatMap((packageFile) => { - const manifest = JSON.parse(fs.readFileSync(packageFile, "utf8")); - return [ - ...Object.entries(manifest.dependencies ?? {}), - ...Object.entries(manifest.devDependencies ?? {}), - ...Object.entries(manifest.optionalDependencies ?? {}), - ...Object.entries(manifest.peerDependencies ?? {}), - ].filter( - ([packageName, source]) => - packageProjects.has(packageName) && - (mode === "workspace" ? source !== "workspace:*" : source === "workspace:*"), - ); - }); - if (remainingWorkspaceReferences.length > 0) { - throw new Error("Orbian dependency source verification failed"); - } - const updatedWorkspace = fs.readFileSync(workspaceFile, "utf8"); - const updatedLock = fs.readFileSync(lockFile, "utf8"); - if ( - mode === "workspace" - ? workspacePackages.some((workspacePackage) => !updatedWorkspace.includes(workspacePackage)) - : workspacePackages.some( - (workspacePackage) => - updatedWorkspace.includes(workspacePackage) || updatedLock.includes(workspacePackage), - ) - ) { - throw new Error("Orbian workspace boundary verification failed"); - } - - console.log( - mode === "workspace" - ? "Community packages now use the sibling Orbian workspace." - : `Community packages now use immutable Orbian artifacts from ${mode}.`, - ); -} catch (cause) { - for (const [file, contents] of originals) fs.writeFileSync(file, contents); - throw cause; -} diff --git a/selfhost/.env.example b/selfhost/.env.example index 9aebd8c9..64693d30 100644 --- a/selfhost/.env.example +++ b/selfhost/.env.example @@ -1,8 +1,22 @@ +# `production` for real deployments; `local-evaluation` relaxes auth for local +# development and is what the integration suites expect. SELFHOST_MODE=production DATABASE_USERNAME=voidhash DATABASE_PASSWORD=replace-with-a-random-password DATABASE_NAME=voidhash DATABASE_SSL=false +# Direct-TCP overrides for the migration process (`pnpm migrate`) and the local +# migration CLI (`pnpm db:migrate`). Each one falls back to its DATABASE_* +# counterpart, so leave them unset unless DATABASE_HOST points at a sandboxed or +# proxied endpoint — a connection broker, or a +# Hyperdrive-style local socket — that only resolves inside the runtime serving +# requests. Migrations run in their own process and need the origin address. +# DATABASE_DIRECT_HOST=postgres +# DATABASE_DIRECT_PORT=5432 +# DATABASE_DIRECT_NAME=voidhash +# DATABASE_DIRECT_USERNAME=voidhash +# DATABASE_DIRECT_PASSWORD=replace-with-a-random-password +# DATABASE_DIRECT_SSL=false # Optional analytics profile. Leave CLICKHOUSE_URL unset for the core stack. # CLICKHOUSE_URL=http://clickhouse:8123 CLICKHOUSE_DATABASE=voidhash @@ -65,3 +79,31 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true SMTP_VERIFY_ON_START=true MAILPIT_SMTP_PORT=1025 MAILPIT_UI_PORT=8025 + +# ── Local development & integration tests ──────────────────────────────────── +# Used together with docker-compose.dev.yml: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml \ +# --profile analytics up -d --build +# `pnpm test:integration` (repo root) reads this file and derives host-side +# connection settings from the values below, so the whole suite runs against +# this stack with no additional configuration. + +# Host ports published by the dev overlay. Change them only when another local +# service already owns the default. +DATABASE_HOST_PORT=5432 +COMPILER_HOST_PORT=5002 + +# To enable the analytics profile end-to-end (the compose service, migrations, +# and the ClickHouse integration suite), uncomment CLICKHOUSE_URL above. + +# Browser used by the screenshot integration tests on the host. The container +# ships its own chromium; this is only for host-side test runs. +# PLATFORM_NODE_CHROMIUM_EXECUTABLE_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome + +# ── Values you must provide ────────────────────────────────────────────────── +# WORKOS_* — authentication. Required to sign in to the dashboard; the +# stack boots with the placeholders above but auth flows fail +# until real values are set. +# OPENAI_API_KEY / ANTHROPIC_API_KEY — required only for the AI designer agent. +# EXCHANGE_RATE_API_KEY — required only for the FX rate sync job. +# ENCRYPTION_KEY — required for payment-provider credential storage. diff --git a/selfhost/README.md b/selfhost/README.md index 0ed1eec3..7e4b533a 100644 --- a/selfhost/README.md +++ b/selfhost/README.md @@ -11,6 +11,30 @@ application services and platform contracts as the Cloudflare composition. ClickHouse OSS is an optional `analytics` profile; without it, capture still processes identity state in PostgreSQL and analytics reads return empty results. +## Local development + +The stack doubles as the default development environment. The dev overlay +publishes Postgres and the compiler to the host so tests and tooling reach the +same services the app uses: + +```sh +cp selfhost/.env.example selfhost/.env # adjust ports/credentials as needed +pnpm stack:up # compose up with the analytics profile +pnpm test:integration # every integration suite, sequentially +pnpm selfhost:smoke # e2e against the running app +``` + +`pnpm test:integration` reads `selfhost/.env`, derives host-side connection +settings (container hostnames become `127.0.0.1` plus the published port), +enables every suite's opt-in flag, and runs the suites one after another — +they share one Postgres and one ClickHouse, so parallel runs would race on +schema setup. Pass suite names to narrow the run, for example +`pnpm test:integration platform-cluster selfhost-entry`. + +In the production compose file PostgreSQL stays unpublished and the compiler +is reachable only on its internal network; only the dev overlay +(`docker-compose.dev.yml`) exposes them. + ## Start For an infrastructure and Mimic evaluation with local defaults: diff --git a/selfhost/docker-compose.dev.yml b/selfhost/docker-compose.dev.yml new file mode 100644 index 00000000..5a95309c --- /dev/null +++ b/selfhost/docker-compose.dev.yml @@ -0,0 +1,24 @@ +# Development overlay for the self-host stack. +# +# The production compose keeps Postgres unpublished and the compiler on an +# internal-only network. Local development runs tests and tooling on the host +# against the same stack, so this overlay publishes both: +# +# docker compose -f docker-compose.yml -f docker-compose.dev.yml \ +# --profile analytics up -d --build +# +# Host ports come from `.env` (see `.env.example`); every default matches what +# the integration suites assume. +services: + postgres: + ports: + - "${DATABASE_HOST_PORT:-5432}:5432" + + compiler: + # The internal-only network blocks host port publishing, so the compiler + # also joins the default network in development. + networks: + - compiler + - default + ports: + - "${COMPILER_HOST_PORT:-5002}:5002" diff --git a/selfhost/entry/Dockerfile b/selfhost/entry/Dockerfile index 017edcb1..01d89e98 100644 --- a/selfhost/entry/Dockerfile +++ b/selfhost/entry/Dockerfile @@ -11,9 +11,9 @@ WORKDIR /repo COPY . . RUN corepack pnpm@11.1.3 install --frozen-lockfile --filter @voidhash/selfhost-entry... --filter @voidhash/www... --ignore-scripts --config.node-linker=isolated RUN VITE_APP_API_URL= VITE_APP_ENV=production VOIDHASH_SELFHOST_BUNDLE=true corepack pnpm@11.1.3 exec turbo build --filter @voidhash/www -RUN rm -rf /out && corepack pnpm@11.1.3 --config.ignore-scripts=true --config.node-linker=isolated --config.block-exotic-subdeps=false --filter @voidhash/selfhost-entry deploy --prod --legacy /out +RUN rm -rf /out && corepack pnpm@11.1.3 --config.ignore-scripts=true --config.node-linker=isolated --filter @voidhash/selfhost-entry deploy --prod --legacy /out RUN node scripts/check-selfhost-runtime-boundary.mjs /out -RUN rm -rf /www && corepack pnpm@11.1.3 --config.ignore-scripts=true --config.node-linker=hoisted --config.block-exotic-subdeps=false --config.allow-unused-patches=true --filter @voidhash/www deploy --prod --legacy /www +RUN rm -rf /www && corepack pnpm@11.1.3 --config.ignore-scripts=true --config.node-linker=hoisted --config.allow-unused-patches=true --filter @voidhash/www deploy --prod --legacy /www RUN node scripts/check-selfhost-runtime-boundary.mjs /www FROM node:22-bookworm-slim AS runtime diff --git a/selfhost/entry/package.json b/selfhost/entry/package.json index 7b24c4e3..a9bf61cb 100644 --- a/selfhost/entry/package.json +++ b/selfhost/entry/package.json @@ -35,8 +35,8 @@ "@voidhash/paywall-renderer-preact": "workspace:*", "@voidhash/paywall-renderer-web-core": "workspace:*", "@voidhash/paywalls": "workspace:*", - "@orbian/sdk": "https://pkg.voidha.sh/orbian-sdk/fc444bad5db7da1302f6fa0b1a9bd7cadd800526", - "@orbian/node": "https://pkg.voidha.sh/orbian-node/fc444bad5db7da1302f6fa0b1a9bd7cadd800526", + "@voidhash/platform": "workspace:*", + "@voidhash/platform-node": "workspace:*", "effect": "catalog:", "esbuild": "^0.25.10", "jose": "catalog:", diff --git a/selfhost/entry/src/DurableEntityAlarms.ts b/selfhost/entry/src/DurableEntityAlarms.ts index 5859f238..77131bab 100644 --- a/selfhost/entry/src/DurableEntityAlarms.ts +++ b/selfhost/entry/src/DurableEntityAlarms.ts @@ -1,5 +1,5 @@ -import type { DurableEntityAddress } from "@orbian/sdk/DurableEntity"; -import type { NodeDurableEntityControlShape } from "@orbian/node/DurableEntity"; +import type { DurableEntityAddress } from "@voidhash/platform/DurableEntity"; +import type { NodeDurableEntityControlShape } from "@voidhash/platform-node/DurableEntity"; import { Effect } from "effect"; /** Handler for one durable-entity alarm type. */ diff --git a/selfhost/entry/src/agent/AgentNodeWebSocket.ts b/selfhost/entry/src/agent/AgentNodeWebSocket.ts index 9dc9e00e..f14997cb 100644 --- a/selfhost/entry/src/agent/AgentNodeWebSocket.ts +++ b/selfhost/entry/src/agent/AgentNodeWebSocket.ts @@ -19,8 +19,8 @@ import { AuthSession } from "@voidhash/core/domain/auth/Auth"; import { AgentSessionIndexService, LocalUserSessionService, Workos } from "@voidhash/core/services"; import type { AuthTokenVerifier } from "@voidhash/core/services/auth/AuthTokenVerifier"; import { Db } from "@voidhash/db"; -import type { DurableEntityHostShape } from "@orbian/sdk/DurableEntity"; -import { makeNodeDurableEntitySession } from "@orbian/node/NodeDurableEntitySession"; +import type { DurableEntityHostShape } from "@voidhash/platform/DurableEntity"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-node/NodeDurableEntitySession"; import { Context, Effect, Redacted } from "effect"; import * as HttpHeaders from "effect/unstable/http/Headers"; import { WebSocketServer, type RawData } from "ws"; diff --git a/selfhost/entry/src/backend/Analytics.ts b/selfhost/entry/src/backend/Analytics.ts index afa10834..84aab08c 100644 --- a/selfhost/entry/src/backend/Analytics.ts +++ b/selfhost/entry/src/backend/Analytics.ts @@ -22,12 +22,12 @@ import { import { ClickhouseWebClient } from "@voidhash/clickhouse-db/clickhouse-client-web"; import { PersonIdentityService } from "@voidhash/core/services/personIdentity/PersonIdentityService"; import { Db } from "@voidhash/db"; -import { KeyValueStore } from "@orbian/sdk/KeyValueStore"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; -import { QueueDriver } from "@orbian/sdk/Queue"; -import { PgKeyValueStoreLive } from "@orbian/node/KeyValueStore"; -import { NodePlatformRuntimeLive } from "@orbian/node/PlatformRuntime"; -import { PgQueueLive } from "@orbian/node/Queue"; +import { KeyValueStore } from "@voidhash/platform/KeyValueStore"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { QueueDriver } from "@voidhash/platform/Queue"; +import { PgKeyValueStoreLive } from "@voidhash/platform-node/KeyValueStore"; +import { NodePlatformRuntimeLive } from "@voidhash/platform-node/PlatformRuntime"; +import { PgQueueLive } from "@voidhash/platform-node/Queue"; import { Context, Effect, Layer, Redacted } from "effect"; import type { SelfhostRuntimeConfig } from "../config.ts"; diff --git a/selfhost/entry/src/backend/Backend.ts b/selfhost/entry/src/backend/Backend.ts index 9a9038d8..dedce604 100644 --- a/selfhost/entry/src/backend/Backend.ts +++ b/selfhost/entry/src/backend/Backend.ts @@ -11,7 +11,7 @@ import type { PublicFileStore } from "@voidhash/core/services/storage/PublicFile import { PaywallAssetConfig } from "@voidhash/core/services/paywallLocations/PaywallAssetConfig"; import { Db } from "@voidhash/db"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; -import { NodePlatformRuntimeLive } from "@orbian/node/PlatformRuntime"; +import { NodePlatformRuntimeLive } from "@voidhash/platform-node/PlatformRuntime"; import { Effect, Layer } from "effect"; import type { SelfhostRuntimeConfig, SelfhostWorkosConfig } from "../config.ts"; diff --git a/selfhost/entry/src/backend/Background.ts b/selfhost/entry/src/backend/Background.ts index 5a6046b7..86a2fb9d 100644 --- a/selfhost/entry/src/backend/Background.ts +++ b/selfhost/entry/src/backend/Background.ts @@ -9,9 +9,9 @@ import { Db } from "@voidhash/db"; import { type CronJob, CronScheduler, -} from "@orbian/sdk/CronScheduler"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; -import { PgCronSchedulerLive } from "@orbian/node/CronScheduler"; +} from "@voidhash/platform/CronScheduler"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { PgCronSchedulerLive } from "@voidhash/platform-node/CronScheduler"; import { Context, Effect, Layer, Redacted } from "effect"; import type { SelfhostRuntimeConfig } from "../config.ts"; diff --git a/selfhost/entry/src/backend/IdentityCompletionWorkflow.ts b/selfhost/entry/src/backend/IdentityCompletionWorkflow.ts index 0fdf7279..b00e8663 100644 --- a/selfhost/entry/src/backend/IdentityCompletionWorkflow.ts +++ b/selfhost/entry/src/backend/IdentityCompletionWorkflow.ts @@ -13,7 +13,7 @@ import { } from "@voidhash/core/domain/person/Person"; import { IdentityMutationService } from "@voidhash/core/services/personIdentity/IdentityMutationService"; import { generateId } from "@voidhash/core/utils/generate-id"; -import { WorkflowRunner } from "@orbian/sdk/Workflow"; +import { WorkflowRunner } from "@voidhash/platform/Workflow"; import { Effect, Layer, Schema } from "effect"; import { IdentifyDistinctIdCompletionDefinition } from "./WorkflowDefinitions.ts"; diff --git a/selfhost/entry/src/backend/ObjectStores.ts b/selfhost/entry/src/backend/ObjectStores.ts index 0b38c4f9..eca8683f 100644 --- a/selfhost/entry/src/backend/ObjectStores.ts +++ b/selfhost/entry/src/backend/ObjectStores.ts @@ -6,12 +6,12 @@ import { PublicFileStore, PublicFileStoreError, } from "@voidhash/core/services/storage/PublicFileStore"; -import { ObjectStore, ObjectStoreError } from "@orbian/sdk/ObjectStore"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; +import { ObjectStore, ObjectStoreError } from "@voidhash/platform/ObjectStore"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; import { S3ObjectStoreLive, type S3ObjectStoreConfig, -} from "@orbian/node/ObjectStore"; +} from "@voidhash/platform-node/ObjectStore"; import { Effect, Layer, Option } from "effect"; const objectStoreCause = (cause: unknown): string => diff --git a/selfhost/entry/src/backend/PaymentProviderWorkflows.ts b/selfhost/entry/src/backend/PaymentProviderWorkflows.ts index a52dd4a7..005f9f09 100644 --- a/selfhost/entry/src/backend/PaymentProviderWorkflows.ts +++ b/selfhost/entry/src/backend/PaymentProviderWorkflows.ts @@ -4,7 +4,7 @@ import { GooglePlayWebhookHandlerService } from "@voidhash/core/services/payment import { StripeWebhookHandlerService } from "@voidhash/core/services/paymentProviders/stripe/stripe-webhook-handler-service"; import { IdentifyDistinctIdCompletionWorkflow } from "@voidhash/core/services/personIdentity/IdentifyDistinctIdCompletionWorkflow"; import { Db } from "@voidhash/db"; -import { WorkflowRunner } from "@orbian/sdk/Workflow"; +import { WorkflowRunner } from "@voidhash/platform/Workflow"; import { Effect, Layer, Schema } from "effect"; import { diff --git a/selfhost/entry/src/backend/Push.ts b/selfhost/entry/src/backend/Push.ts index 9f130b23..454b93ed 100644 --- a/selfhost/entry/src/backend/Push.ts +++ b/selfhost/entry/src/backend/Push.ts @@ -13,8 +13,8 @@ import { } from "@voidhash/core/services/notifications/PushDeliveryDispatch"; import { PaymentConfigSecretCrypto } from "@voidhash/core/utils/crypto/PaymentConfigSecretCrypto"; import { Db } from "@voidhash/db"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; -import { QueueDriver } from "@orbian/sdk/Queue"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { QueueDriver } from "@voidhash/platform/Queue"; import { Context, Effect, Layer } from "effect"; import type { SelfhostRuntimeConfig } from "../config.ts"; diff --git a/selfhost/entry/src/backend/Thumbnails.ts b/selfhost/entry/src/backend/Thumbnails.ts index e080e6a3..8ac83deb 100644 --- a/selfhost/entry/src/backend/Thumbnails.ts +++ b/selfhost/entry/src/backend/Thumbnails.ts @@ -16,14 +16,14 @@ import type { PreviewTree, SnapshotNode, } from "@voidhash/paywall-renderer-web-core"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; -import { QueueDriver } from "@orbian/sdk/Queue"; -import { Screenshot } from "@orbian/sdk/Screenshot"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { QueueDriver } from "@voidhash/platform/Queue"; +import { Screenshot } from "@voidhash/platform/Screenshot"; import { ChromiumScreenshotLive, type ChromiumScreenshotConfig, -} from "@orbian/node/Screenshot"; -import { NodePlatformRuntimeLive } from "@orbian/node/PlatformRuntime"; +} from "@voidhash/platform-node/Screenshot"; +import { NodePlatformRuntimeLive } from "@voidhash/platform-node/PlatformRuntime"; import { Cause, Effect, Layer } from "effect"; import { mimicDocumentIdleQueueName } from "../mimic/MimicDocumentIdleQueue.ts"; diff --git a/selfhost/entry/src/backend/WebhookDeliveryWorkflow.ts b/selfhost/entry/src/backend/WebhookDeliveryWorkflow.ts index acc04193..70a44251 100644 --- a/selfhost/entry/src/backend/WebhookDeliveryWorkflow.ts +++ b/selfhost/entry/src/backend/WebhookDeliveryWorkflow.ts @@ -3,7 +3,7 @@ import { nextWebhookDeliveryRetryTime, } from "@voidhash/core/services/webhookDispatch/WebhookDeliveryService"; import { Db } from "@voidhash/db"; -import { WorkflowRunner } from "@orbian/sdk/Workflow"; +import { WorkflowRunner } from "@voidhash/platform/Workflow"; import { Effect, Layer, Schema } from "effect"; import { DeliverWebhookDefinition } from "./WorkflowDefinitions.ts"; diff --git a/selfhost/entry/src/backend/WorkflowDefinitions.ts b/selfhost/entry/src/backend/WorkflowDefinitions.ts index 5109b014..e7d70c46 100644 --- a/selfhost/entry/src/backend/WorkflowDefinitions.ts +++ b/selfhost/entry/src/backend/WorkflowDefinitions.ts @@ -1,4 +1,4 @@ -import { defineWorkflow } from "@orbian/sdk/Workflow"; +import { defineWorkflow } from "@voidhash/platform/Workflow"; import { Schema } from "effect"; export const DeliverWebhookDefinition = defineWorkflow({ diff --git a/selfhost/entry/src/backend/WorkflowPorts.ts b/selfhost/entry/src/backend/WorkflowPorts.ts index fbb850d8..7df7912c 100644 --- a/selfhost/entry/src/backend/WorkflowPorts.ts +++ b/selfhost/entry/src/backend/WorkflowPorts.ts @@ -6,10 +6,10 @@ import { StripeReplayParkedNotificationsWorkflow } from "@voidhash/core/services import { IdentifyDistinctIdCompletionWorkflow } from "@voidhash/core/services/personIdentity/IdentifyDistinctIdCompletionWorkflow"; import { WebhookDeliveryWorkflow } from "@voidhash/core/services/webhookDispatch/WebhookDeliveryWorkflow"; import { Db } from "@voidhash/db"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; -import { WorkflowRunner } from "@orbian/sdk/Workflow"; -import { NodePlatformRuntimeLive } from "@orbian/node/PlatformRuntime"; -import { PgWorkflowRunnerLive } from "@orbian/node/Workflow"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { WorkflowRunner } from "@voidhash/platform/Workflow"; +import { NodePlatformRuntimeLive } from "@voidhash/platform-node/PlatformRuntime"; +import { PgWorkflowRunnerLive } from "@voidhash/platform-node/Workflow"; import { Effect, Layer, Redacted } from "effect"; import type { SelfhostRuntimeConfig } from "../config.ts"; diff --git a/selfhost/entry/src/config.ts b/selfhost/entry/src/config.ts index ee18bedb..df39222a 100644 --- a/selfhost/entry/src/config.ts +++ b/selfhost/entry/src/config.ts @@ -1,6 +1,6 @@ import type { DbConfig } from "@voidhash/db/db"; -import type { SmtpMailerConfig } from "@orbian/node/Mailer"; -import type { S3ObjectStoreConfig } from "@orbian/node/ObjectStore"; +import type { SmtpMailerConfig } from "@voidhash/platform-node/Mailer"; +import type { S3ObjectStoreConfig } from "@voidhash/platform-node/ObjectStore"; import { Redacted } from "effect"; const positiveIntegerFromEnv = (name: string, fallback: number): number => { @@ -202,6 +202,32 @@ export const getSelfhostDatabaseConfig = (): DbConfig => { }; }; +/** + * Reads the application database connection used by out-of-band tooling that + * opens its own TCP socket — currently the migration entrypoint, which runs as + * a separate process before the server starts. + * + * Every `DATABASE_DIRECT_*` variable falls back to its `DATABASE_*` counterpart, + * so operators whose application already dials Postgres directly never set them. + * They exist for deployments where `DATABASE_HOST` names a sandboxed or proxied + * endpoint (a connection broker, a Hyperdrive-style local socket) that only + * resolves inside the runtime serving requests. Migrations need multi-statement + * SQL and a session-scoped advisory lock, so they always take the origin. + */ +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 }), + username: process.env.DATABASE_DIRECT_USERNAME?.trim() || fallback.username, + }; +}; + /** Reads the SMTP transport and default sender configuration. */ export const getSelfhostSmtpConfig = (): SmtpMailerConfig => { const username = process.env.SMTP_USERNAME?.trim() || undefined; diff --git a/selfhost/entry/src/index.ts b/selfhost/entry/src/index.ts index 4abf14ba..fd920a90 100644 --- a/selfhost/entry/src/index.ts +++ b/selfhost/entry/src/index.ts @@ -5,7 +5,11 @@ export { makeSelfhostWorkflowRuntimeLive, registerSelfhostWorkflows, } from "./backend/WorkflowPorts.ts"; -export { getSelfhostDatabaseConfig, getSelfhostRuntimeConfig } from "./config.ts"; +export { + getSelfhostDatabaseConfig, + getSelfhostMigrationDatabaseConfig, + getSelfhostRuntimeConfig, +} from "./config.ts"; export { getMimicNodeConfig } from "./mimic/config.ts"; export { makeMimicNodeHostLive, type MimicNodeConfig } from "./mimic/MimicNode.ts"; export { installMimicNodeWebSocketServer } from "./mimic/MimicNodeWebSocket.ts"; diff --git a/selfhost/entry/src/main.ts b/selfhost/entry/src/main.ts index a2c40c72..448ff620 100644 --- a/selfhost/entry/src/main.ts +++ b/selfhost/entry/src/main.ts @@ -16,9 +16,9 @@ import { PaywallThumbnailService } from "@voidhash/core/services/paywallThumbnai import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; import { getConfig as getMimicConfig } from "@voidhash/mimic-db/config"; import { makeRoutesLive } from "@voidhash/mimic-db/http/rpc-app"; -import { DurableEntityHost } from "@orbian/sdk/DurableEntity"; -import { NodeDurableEntityControl } from "@orbian/node/DurableEntity"; -import { SmtpMailerLive } from "@orbian/node/Mailer"; +import { DurableEntityHost } from "@voidhash/platform/DurableEntity"; +import { NodeDurableEntityControl } from "@voidhash/platform-node/DurableEntity"; +import { SmtpMailerLive } from "@voidhash/platform-node/Mailer"; import { Context, Effect, Layer } from "effect"; import { HttpRouter } from "effect/unstable/http"; import { HttpApiBuilder } from "effect/unstable/httpapi"; diff --git a/selfhost/entry/src/migrate.ts b/selfhost/entry/src/migrate.ts index 2ef71203..f3ec2bbd 100644 --- a/selfhost/entry/src/migrate.ts +++ b/selfhost/entry/src/migrate.ts @@ -5,7 +5,7 @@ import { Effect, Layer } from "effect"; import { migrateSelfhostClickhouse } from "./backend/Clickhouse.ts"; import { getSelfhostClickhouseConfig, - getSelfhostDatabaseConfig, + getSelfhostMigrationDatabaseConfig, validateSelfhostSecurityConfig, } from "./config.ts"; import { getMimicNodeConfig } from "./mimic/config.ts"; @@ -15,8 +15,9 @@ NodeRuntime.runMain( Effect.scoped( Effect.gen(function* () { validateSelfhostSecurityConfig(); - const result = yield* runAppDatabaseMigrations(getSelfhostDatabaseConfig()); - yield* Layer.build(makeMimicNodeHostLive(getMimicNodeConfig())); + const connection = getSelfhostMigrationDatabaseConfig(); + const result = yield* runAppDatabaseMigrations(connection); + yield* Layer.build(makeMimicNodeHostLive(getMimicNodeConfig(connection))); yield* migrateSelfhostClickhouse(getSelfhostClickhouseConfig()); yield* Effect.logInfo("Self-host database migrations are ready", { applied: result.applied.length, diff --git a/selfhost/entry/src/mimic/MimicDocumentIdleQueue.ts b/selfhost/entry/src/mimic/MimicDocumentIdleQueue.ts index b617f2e0..660fc994 100644 --- a/selfhost/entry/src/mimic/MimicDocumentIdleQueue.ts +++ b/selfhost/entry/src/mimic/MimicDocumentIdleQueue.ts @@ -2,8 +2,8 @@ import { MimicDocumentIdleMessage, type MimicDocumentIdleMessageType, } from "@voidhash/mimic-db/ws/idle-notify"; -import { PlatformRuntime } from "@orbian/sdk/PlatformRuntime"; -import { QueueDriver } from "@orbian/sdk/Queue"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { QueueDriver } from "@voidhash/platform/Queue"; import { Effect } from "effect"; /** Logical PostgreSQL queue carrying idle Mimic document revisions. */ diff --git a/selfhost/entry/src/mimic/MimicNode.ts b/selfhost/entry/src/mimic/MimicNode.ts index 6eed75ac..3fad3cf4 100644 --- a/selfhost/entry/src/mimic/MimicNode.ts +++ b/selfhost/entry/src/mimic/MimicNode.ts @@ -11,12 +11,12 @@ import { makePgDocumentStore, type PgDocumentConfig, } from "@voidhash/mimic-db/core/pg-store"; -import { DurableEntityHost } from "@orbian/sdk/DurableEntity"; +import { DurableEntityHost } from "@voidhash/platform/DurableEntity"; import { NodeDurableEntityControl, type PgDurableEntityConfig, PgDurableEntityHostLive, -} from "@orbian/node/DurableEntity"; +} from "@voidhash/platform-node/DurableEntity"; import { Effect, Layer } from "effect"; import { PgControlStoreLive } from "./PgControlStore.ts"; diff --git a/selfhost/entry/src/mimic/MimicNodeWebSocket.ts b/selfhost/entry/src/mimic/MimicNodeWebSocket.ts index 60150652..5b763634 100644 --- a/selfhost/entry/src/mimic/MimicNodeWebSocket.ts +++ b/selfhost/entry/src/mimic/MimicNodeWebSocket.ts @@ -19,9 +19,9 @@ import { DurableEntityHost, type DurableEntityContext, makeDurableEntityAddress, -} from "@orbian/sdk/DurableEntity"; -import type { NodeDurableEntityControlShape } from "@orbian/node/DurableEntity"; -import { makeNodeDurableEntitySession } from "@orbian/node/NodeDurableEntitySession"; +} from "@voidhash/platform/DurableEntity"; +import type { NodeDurableEntityControlShape } from "@voidhash/platform-node/DurableEntity"; +import { makeNodeDurableEntitySession } from "@voidhash/platform-node/NodeDurableEntitySession"; import { Duration, Effect, Fiber, Semaphore } from "effect"; import WebSocket, { WebSocketServer, type RawData } from "ws"; diff --git a/selfhost/entry/src/mimic/PgControlStore.ts b/selfhost/entry/src/mimic/PgControlStore.ts index 423b1608..d7bac066 100644 --- a/selfhost/entry/src/mimic/PgControlStore.ts +++ b/selfhost/entry/src/mimic/PgControlStore.ts @@ -10,7 +10,7 @@ import type { UserRecord, } from "@voidhash/mimic-db/core/store"; import { ControlStore } from "@voidhash/mimic-db/core/store"; -import type { PgDurableEntityConfig } from "@orbian/node/DurableEntity"; +import type { PgDurableEntityConfig } from "@voidhash/platform-node/DurableEntity"; import { Effect, Layer } from "effect"; import { SqlClient } from "effect/unstable/sql"; diff --git a/selfhost/entry/src/mimic/config.ts b/selfhost/entry/src/mimic/config.ts index b9e414b1..57ea70cc 100644 --- a/selfhost/entry/src/mimic/config.ts +++ b/selfhost/entry/src/mimic/config.ts @@ -1,40 +1,33 @@ +import type { DbConfig } from "@voidhash/db/db"; import { makePgDocumentConfig } from "@voidhash/mimic-db/core/pg-store"; -import type { PgDurableEntityConfig } from "@orbian/node/DurableEntity"; +import type { PgDurableEntityConfig } from "@voidhash/platform-node/DurableEntity"; import { Redacted } from "effect"; +import { getSelfhostDatabaseConfig } from "../config.ts"; import type { MimicNodeConfig } from "./MimicNode.ts"; -const numberFromEnv = (name: string, fallback: number): number => { - const value = process.env[name]?.trim(); - if (!value) return fallback; - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error(`${name} must be a positive integer`); - } - return parsed; -}; - -/** Reads the self-host mimic database configuration from environment variables. */ -export const getMimicNodeConfig = (): MimicNodeConfig => { - const host = process.env.DATABASE_HOST?.trim() || "127.0.0.1"; - const port = numberFromEnv("DATABASE_PORT", 5432); - const database = process.env.DATABASE_NAME?.trim() || "voidhash"; - const username = process.env.DATABASE_USERNAME?.trim() || "voidhash"; - const passwordValue = process.env.DATABASE_PASSWORD ?? "password"; - const password = Redacted.make(passwordValue); +/** + * Reads the self-host mimic database configuration. Defaults to the shared + * application connection; the migration entrypoint passes the direct-TCP + * connection instead, because building this host also issues DDL. + */ +export const getMimicNodeConfig = ( + connection: DbConfig = getSelfhostDatabaseConfig(), +): MimicNodeConfig => { + const { databaseName: database, host, port, username } = connection; const databaseConfig: PgDurableEntityConfig = { host, port, database, username, - password, + password: Redacted.make(connection.password), }; const documents = makePgDocumentConfig({ host, port, database, username, - password: passwordValue, + password: connection.password, }); return { database: databaseConfig, documents }; }; diff --git a/selfhost/entry/src/mimic/main.ts b/selfhost/entry/src/mimic/main.ts index 96bc16d4..f999dc9b 100644 --- a/selfhost/entry/src/mimic/main.ts +++ b/selfhost/entry/src/mimic/main.ts @@ -4,12 +4,12 @@ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; import { HostServiceTag } from "@voidhash/mimic-db/app/hostService"; import { getConfig } from "@voidhash/mimic-db/config"; import { makeRoutesLive } from "@voidhash/mimic-db/http/rpc-app"; -import { DurableEntityHost } from "@orbian/sdk/DurableEntity"; +import { DurableEntityHost } from "@voidhash/platform/DurableEntity"; import { NodeDurableEntityControl, -} from "@orbian/node/DurableEntity"; -import { NodePlatformRuntimeLive } from "@orbian/node/PlatformRuntime"; -import { PgQueueLive } from "@orbian/node/Queue"; +} from "@voidhash/platform-node/DurableEntity"; +import { NodePlatformRuntimeLive } from "@voidhash/platform-node/PlatformRuntime"; +import { PgQueueLive } from "@voidhash/platform-node/Queue"; import { Context, Effect, Layer } from "effect"; import { HttpRouter } from "effect/unstable/http"; diff --git a/selfhost/entry/tests/AgentNodeWebSocket.integration.test.ts b/selfhost/entry/tests/AgentNodeWebSocket.integration.test.ts index 454a884f..8369618f 100644 --- a/selfhost/entry/tests/AgentNodeWebSocket.integration.test.ts +++ b/selfhost/entry/tests/AgentNodeWebSocket.integration.test.ts @@ -8,7 +8,7 @@ import { Workos, } from "@voidhash/core/services"; import { Db } from "@voidhash/db"; -import { makeMemoryDurableEntityHost } from "@orbian/node/MemoryDurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-node/MemoryDurableEntity"; import { Context, Effect, Redacted } from "effect"; import { WebSocket } from "ws"; import { afterEach, describe, expect, it } from "vite-plus/test"; diff --git a/selfhost/entry/tests/BackendAdapters.test.ts b/selfhost/entry/tests/BackendAdapters.test.ts index 432de8d3..e6ba2308 100644 --- a/selfhost/entry/tests/BackendAdapters.test.ts +++ b/selfhost/entry/tests/BackendAdapters.test.ts @@ -3,7 +3,7 @@ import { Effect, Redacted } from "effect"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { MemoryProjectSchemaCacheLive } from "../src/backend/ProjectSchemaCache.ts"; -import { getSelfhostRuntimeConfig } from "../src/config.ts"; +import { getSelfhostMigrationDatabaseConfig, getSelfhostRuntimeConfig } from "../src/config.ts"; const originalEnvironment = { ...process.env }; @@ -120,6 +120,47 @@ describe("self-host runtime configuration", () => { expect(getSelfhostRuntimeConfig().database).toMatchObject({ host: "postgres", ssl: false }); }); + it("falls back to the application connection for migrations", () => { + process.env.DATABASE_HOST = "postgres"; + process.env.DATABASE_PORT = "6543"; + process.env.DATABASE_NAME = "voidhash"; + process.env.DATABASE_USERNAME = "voidhash"; + process.env.DATABASE_PASSWORD = "application-secret"; + process.env.DATABASE_SSL = "false"; + delete process.env.DATABASE_DIRECT_HOST; + + expect(getSelfhostMigrationDatabaseConfig()).toEqual({ + databaseName: "voidhash", + host: "postgres", + password: "application-secret", + port: 6543, + ssl: false, + username: "voidhash", + }); + }); + + it("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"; + process.env.DATABASE_USERNAME = "voidhash"; + process.env.DATABASE_PASSWORD = "application-secret"; + process.env.DATABASE_SSL = "false"; + process.env.DATABASE_DIRECT_HOST = "postgres"; + process.env.DATABASE_DIRECT_PORT = "6543"; + process.env.DATABASE_DIRECT_SSL = "true"; + + expect(getSelfhostRuntimeConfig().database.host).toBe("broker.internal.local"); + expect(getSelfhostMigrationDatabaseConfig()).toEqual({ + databaseName: "voidhash", + host: "postgres", + password: "application-secret", + port: 6543, + ssl: true, + username: "voidhash", + }); + }); + it("enables ClickHouse only when its URL is configured", () => { process.env.CLICKHOUSE_URL = "http://clickhouse:8123"; process.env.CLICKHOUSE_DATABASE = "analytics"; diff --git a/selfhost/entry/tests/Background.integration.test.ts b/selfhost/entry/tests/Background.integration.test.ts index 489084de..80610cf1 100644 --- a/selfhost/entry/tests/Background.integration.test.ts +++ b/selfhost/entry/tests/Background.integration.test.ts @@ -37,7 +37,12 @@ describePg("self-host scheduled jobs", () => { ORDER BY job_name `) .pipe(Effect.catch(() => Effect.succeed([]))); - const registered = rows.map((row) => String(row.jobName)); + // Drizzle's `execute` yields a driver QueryResult on pg rather + // than a bare array, so normalize before reading rows. + const resultRows = Array.isArray(rows) + ? rows + : ((rows as { rows?: ReadonlyArray> }).rows ?? []); + const registered = resultRows.map((row) => String(row.jobName)); if (registered.length === requiredJobNames.length) return registered; yield* Effect.sleep("25 millis"); } diff --git a/selfhost/entry/tests/MimicDocumentIdle.test.ts b/selfhost/entry/tests/MimicDocumentIdle.test.ts index e11b14d6..4685f765 100644 --- a/selfhost/entry/tests/MimicDocumentIdle.test.ts +++ b/selfhost/entry/tests/MimicDocumentIdle.test.ts @@ -3,9 +3,9 @@ import { IDLE_NOTIFIED_SEQ_KEY, type MimicDocumentIdleMessageType, } from "@voidhash/mimic-db/ws/idle-notify"; -import { makeDurableEntityAddress } from "@orbian/sdk/DurableEntity"; -import type { NodeDurableEntityControlShape } from "@orbian/node/DurableEntity"; -import { makeMemoryDurableEntityHost } from "@orbian/node/MemoryDurableEntity"; +import { makeDurableEntityAddress } from "@voidhash/platform/DurableEntity"; +import type { NodeDurableEntityControlShape } from "@voidhash/platform-node/DurableEntity"; +import { makeMemoryDurableEntityHost } from "@voidhash/platform-node/MemoryDurableEntity"; import { Effect } from "effect"; import { describe, expect, it, vi } from "vitest"; diff --git a/selfhost/entry/tests/MimicNode.integration.test.ts b/selfhost/entry/tests/MimicNode.integration.test.ts index 06d7f7f5..fe821bab 100644 --- a/selfhost/entry/tests/MimicNode.integration.test.ts +++ b/selfhost/entry/tests/MimicNode.integration.test.ts @@ -6,8 +6,8 @@ import type { SchemaObject, Value } from "@voidhash/mimic-core"; import { DurableEntityHost, makeDurableEntityAddress, -} from "@orbian/sdk/DurableEntity"; -import { NodeDurableEntityControl } from "@orbian/node/DurableEntity"; +} from "@voidhash/platform/DurableEntity"; +import { NodeDurableEntityControl } from "@voidhash/platform-node/DurableEntity"; import { Effect, ManagedRuntime, Redacted } from "effect"; import { describe, expect, it } from "vitest"; import WebSocket from "ws"; diff --git a/selfhost/platform-cluster/package.json b/selfhost/platform-cluster/package.json new file mode 100644 index 00000000..2c21772f --- /dev/null +++ b/selfhost/platform-cluster/package.json @@ -0,0 +1,36 @@ +{ + "name": "@voidhash/platform-cluster", + "version": "0.0.1-alpha.1", + "private": true, + "license": "AGPL-3.0-only", + "repository": { + "type": "git", + "url": "https://github.com/voidhashcom/voidhash", + "directory": "selfhost/platform-cluster" + }, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./CronScheduler": "./src/CronScheduler.ts", + "./DurableEntity": "./src/DurableEntity.ts", + "./PlatformRuntime": "./src/PlatformRuntime.ts", + "./Queue": "./src/Queue.ts", + "./Topology": "./src/Topology.ts", + "./Workflow": "./src/Workflow.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run -c vitest.mts" + }, + "dependencies": { + "@voidhash/platform": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@types/node": "^24.0.12", + "@voidhash/tsconfig": "workspace:*", + "typescript": "catalog:", + "vite-plus": "catalog:", + "@effect/sql-pg": "catalog:" + } +} diff --git a/selfhost/platform-cluster/src/CronScheduler.ts b/selfhost/platform-cluster/src/CronScheduler.ts new file mode 100644 index 00000000..864dfa1c --- /dev/null +++ b/selfhost/platform-cluster/src/CronScheduler.ts @@ -0,0 +1,179 @@ +import { + type CronJob, + type CronRunOptions, + CronScheduler, + CronSchedulerError, + type CronSchedulerShape, +} from "@voidhash/platform/CronScheduler"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { Cause, Cron, Effect, Layer, Result, Semaphore } from "effect"; +import { ClusterCron, Sharding } from "effect/unstable/cluster"; +import { KeyValueStore } from "effect/unstable/persistence"; + +interface SlotState { + readonly lastScheduledAtMs: number | undefined; + readonly nextScheduledAtMs: number; +} + +const schedulerError = (jobName: string, operation: string, cause: unknown) => + new CronSchedulerError({ jobName, operation, cause: String(cause) }); + +const parseCron = (job: CronJob) => { + const parsed = Cron.parse(job.expression, job.timeZone); + return Result.isSuccess(parsed) + ? Effect.succeed(parsed.success) + : Effect.fail(schedulerError(job.name, "parse", parsed.failure.message)); +}; + +const stateKey = (jobName: string): string => `voidhash/platform-cluster/cron/${jobName}`; + +const decodeState = (raw: string | undefined): SlotState | undefined => { + if (raw === undefined) return undefined; + try { + const parsed = JSON.parse(raw) as Partial; + return typeof parsed.nextScheduledAtMs === "number" + ? { + lastScheduledAtMs: + typeof parsed.lastScheduledAtMs === "number" ? parsed.lastScheduledAtMs : undefined, + nextScheduledAtMs: parsed.nextScheduledAtMs, + } + : undefined; + } catch { + return undefined; + } +}; + +/** + * Claims and runs at most one due slot. + * + * Slot bookkeeping lives in the persistence key-value store, and a local + * semaphore serializes concurrent ticks for one job inside this process. + * Cluster-wide exactly-once execution is the job of the `run` operation, which + * delegates to `ClusterCron`; `tick` is the single-shot affordance used by + * tests and by runtimes that drive schedules from an external trigger. + */ +const makeTick = + (store: KeyValueStore.KeyValueStore, locks: Map) => + ( + job: CronJob, + inputNow?: Date, + ): Effect.Effect => { + const lockFor = (name: string): Semaphore.Semaphore => { + let lock = locks.get(name); + if (!lock) { + lock = Semaphore.makeUnsafe(1); + locks.set(name, lock); + } + return lock; + }; + + return PlatformRuntime.pipe( + Effect.andThen(parseCron(job as unknown as CronJob)), + Effect.flatMap((cron) => + lockFor(job.name).withPermit( + Effect.gen(function* () { + const now = inputNow?.getTime() ?? Date.now(); + const key = stateKey(job.name); + const stored = yield* store + .get(key) + .pipe(Effect.mapError((cause) => schedulerError(job.name, "claim", cause))); + const state = decodeState(stored); + + // First sight of a job arms it for its next occurrence: a fresh + // deployment must not immediately fire every schedule it has never + // run before. + if (state === undefined) { + const armed: SlotState = { + lastScheduledAtMs: undefined, + nextScheduledAtMs: Cron.next(cron, new Date(now)).getTime(), + }; + yield* store + .set(key, JSON.stringify(armed)) + .pipe(Effect.mapError((cause) => schedulerError(job.name, "claim", cause))); + return false; + } + + if (state.nextScheduledAtMs > now) return false; + + const scheduledTime = state.nextScheduledAtMs; + const nextScheduledAtMs = Cron.next(cron, new Date(scheduledTime)).getTime(); + + yield* job + .run({ scheduledTime: new Date(scheduledTime), catchUp: scheduledTime < now }) + .pipe( + Effect.catchCause((cause) => + Effect.fail(schedulerError(job.name, "run", Cause.pretty(cause))), + ), + ); + + yield* store + .set( + key, + JSON.stringify({ + lastScheduledAtMs: scheduledTime, + nextScheduledAtMs, + } satisfies SlotState), + ) + .pipe(Effect.mapError((cause) => schedulerError(job.name, "complete", cause))); + + return true; + }), + ), + ), + ) as Effect.Effect; + }; + +/** + * Runs a schedule until interrupted, using `ClusterCron` so exactly one runner + * in the cluster executes each slot even when several are alive. + */ +const makeRun = + (sharding: Sharding.Sharding["Service"]) => + ( + job: CronJob, + _options?: CronRunOptions, + ): Effect.Effect => + PlatformRuntime.pipe( + Effect.andThen(parseCron(job as unknown as CronJob)), + Effect.flatMap((cron) => + Layer.launch( + ClusterCron.make({ + name: job.name, + cron, + execute: Effect.suspend(() => + job.run({ scheduledTime: new Date(), catchUp: false }), + ).pipe( + Effect.catchCause((cause) => + Effect.logError("scheduled job failed", { + jobName: job.name, + cause: Cause.pretty(cause), + }), + ), + ), + }), + ).pipe(Effect.provideService(Sharding.Sharding, sharding)), + ), + ) as Effect.Effect; + +const makeScheduler = ( + store: KeyValueStore.KeyValueStore, + sharding: Sharding.Sharding["Service"], +): CronSchedulerShape => + ({ + tick: makeTick(store, new Map()), + run: makeRun(sharding), + }) as CronSchedulerShape; + +/** Cluster-backed cron scheduler with durable slot state. */ +export const ClusterCronSchedulerLive: Layer.Layer< + CronScheduler, + never, + Sharding.Sharding | KeyValueStore.KeyValueStore +> = Layer.effect( + CronScheduler, + Effect.gen(function* () { + const store = yield* KeyValueStore.KeyValueStore; + const sharding = yield* Sharding.Sharding; + return makeScheduler(store, sharding); + }), +); diff --git a/selfhost/platform-cluster/src/DurableEntity.ts b/selfhost/platform-cluster/src/DurableEntity.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5cec2cc1727b14e869d850f450a9ff7d646c9c4 GIT binary patch literal 4771 zcmb_g+iu%N5bd+RVrmp9WylozD#uQXBt_k(Nl~TgLl7VxxWw+#it6g$duA`Z zNOIzb`azb&o%=a6vnn@@(v}`3M0VFmdf95Rs^qKMmUj13o@;5$?Bv52sTyDdtTj>{{u~d}QhDa#9BO>D78I zGdrW#Vkh;CmU1JSLTO1+nREw6A}+?89H$GR^Rm7@I6QA_V>ui1tZI#wdMN#y+`Si7 zE0^}zN-x+*ZAysNnS}5pQ_x7#8HNJ7qD1%{FUCm>nV&AmSk0bYUDK!U9>U?2bG!eh z|6Jz#Po4UEcIzqHXLKib(ZbLaO8V(2_ZJ}vbMOAVx9+?de13GEsSqFRieW82?&|T-DW%DwW+WdI+T*m@aW@etwQuI>&Pd zS3?3QYqZMn`2wP%qScOSVOuR1BefpcD|e7)q;K(%`*?4*%({F@$1XUell_4oW}zJnpA+P zv=Z<-XGcA`=!ItitRe+SKx9oWvoe>4wndp0q}#fN?`m5kHFeN9Ln7e+MF23F$WK;e zq%K8O{sR@Z5H@J|9-mrW8;I6idvk?b5a8Z- ziFEX7HUZ9$3_%^bFxbwB^nSUR4Lqpjc2w0v6kjJ(pnN3uv(is#GKZR?15p=}3N>Bd zicJgVKn1FRZC{ux|KFsy(k2iJ2g)@i-?|f17Z(dffrlfyaUK4ujabX%hbcJGAv(mL zOlj{82Q4Nw-W(1yK7!S~b52V}k$=Ea5XEc9;y-1kbk5iXCv5?W2K?p<5UZ7CAUmA- z=z&TH0#7}*a8B=lLX^sCky$E$R>raPBodTW3h0xwK#xq+taT*`18s}B1BzMY6$v~k znD>rCt{rrWf}ECGwV;5YO=&O|Gl-+*R4FYnCMtR1WxYPD8da&=5|MBe+*5Ymea9)^ zWH4VE7x^V^+RBze1Ld-*)NX^Yjn}rp3?y&e=YqCM-*JD4tX@gPgj~QMo>^t>x$Dwe zNx`HQ!87Da#-syW1s-y?`;O+Zj|9~>>o>~2-PVZotMd}TWdosOrKUCLwRr-_1WNJ4 zKr3eO4eN9Tg~Ns}l+hURo|ZWPd6vtyK&H+D&ba$}jK!XSoh9mVx^MES z^NNi>kG}DM?Y>>aZ&9w7a@F1@XSv_RV&?-g88-q$=ay4p)ex>{vyND7go4yN>&)4xL@6VL^@Weh+3I8GJ6UVd0H8m~=NZxG_G5N0dbuA!m%0_bYlF|JOCpD^Tm3A)ELrYA(A^vlxivqU~e!G)v7o~m`?ZGoYSZtt7%HlT&Y z;>*bxr|C3&V;y1#b^p=mBT741#)I6Ou&EzRn-~6D6mgh>fRz{}*9;|gq5`is< zgS$MFt3yTNrtfcZ7yWT-=Cn&3t0ZdLAUq>?eh|Dr5JsqL?WZ-)$ko~JIOTfh4b$`I zRJT<%oqFI6>vZ5lE-|^lk1hreTrD^1UOpDaSG4YjnRA=x$+Y#^id%8aR`h#-YEH<2l}8eKZ>eFvkvsrK(PR-uOP2ZcAJIA@!!TNvzXoxOZ(d3GHr* zzn+X;Q}i922ODpK*aPFW<|5p84(6`A3RV28LV9##c AYybcN literal 0 HcmV?d00001 diff --git a/selfhost/platform-cluster/src/PlatformRuntime.ts b/selfhost/platform-cluster/src/PlatformRuntime.ts new file mode 100644 index 00000000..21641f3d --- /dev/null +++ b/selfhost/platform-cluster/src/PlatformRuntime.ts @@ -0,0 +1,13 @@ +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { Layer } from "effect"; + +/** + * Marks effects as running inside a configured cluster runtime. + * + * The marker carries no capabilities; it exists so application code cannot + * reach a platform primitive without a composition root having installed one. + */ +export const ClusterPlatformRuntimeLive: Layer.Layer = Layer.succeed( + PlatformRuntime, + PlatformRuntime.of({}), +); diff --git a/selfhost/platform-cluster/src/Queue.ts b/selfhost/platform-cluster/src/Queue.ts new file mode 100644 index 00000000..24364c96 --- /dev/null +++ b/selfhost/platform-cluster/src/Queue.ts @@ -0,0 +1,292 @@ +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { + QueueConsumerError, + type QueueConsumerOptions, + QueueDriver, + type QueueDriverShape, + QueueProducerError, + type QueueProducer, +} from "@voidhash/platform/Queue"; +import { Cause, Duration, Effect, Layer, Schema, SchemaParser } from "effect"; +import { PersistedQueue } from "effect/unstable/persistence"; + +/** + * Messages are persisted as JSON text rather than as the caller's schema. + * + * Decoding inside the queue would surface a malformed payload as a take + * failure, which the store would then retry forever. Owning the decode step + * lets a poison message be acknowledged and logged, matching the contract's + * at-least-once-with-bounded-retries guarantee. + */ +const transportSchema = Schema.String; + +type TransportQueue = PersistedQueue.PersistedQueue; + +const defaultOptions = { + batchSize: 10, + maxRetries: 3, + retryDelayMillis: 1_000, + pollIntervalMillis: 250, +} as const; + +const positiveInteger = (value: number | undefined, fallback: number): number => + value === undefined || !Number.isFinite(value) || value <= 0 ? fallback : Math.floor(value); + +const nonNegativeInteger = (value: number | undefined, fallback: number): number => + value === undefined || !Number.isFinite(value) || value < 0 ? fallback : Math.floor(value); + +const resolvedOptions = (options: QueueConsumerOptions | undefined) => ({ + batchSize: positiveInteger(options?.batchSize, defaultOptions.batchSize), + maxRetries: nonNegativeInteger(options?.maxRetries, defaultOptions.maxRetries), + retryDelayMillis: nonNegativeInteger(options?.retryDelayMillis, defaultOptions.retryDelayMillis), + pollIntervalMillis: positiveInteger(options?.pollIntervalMillis, defaultOptions.pollIntervalMillis), + deadLetterQueue: options?.deadLetterQueue, +}); + +const producerError = (queueName: string, cause: unknown) => + new QueueProducerError({ queueName, cause: String(cause) }); + +const consumerError = (queueName: string, cause: unknown) => + new QueueConsumerError({ queueName, cause: String(cause) }); + +/** + * Signals that a handler failed and the message should go back to the store. + * + * The store decides ack-versus-retry from the take effect's outcome, so a + * retry has to surface as a failure. Tagging it keeps the driver from + * mistaking a genuine store or SQL failure for an ordinary retry. + */ +class HandlerRetry { + readonly _tag = "HandlerRetry"; +} + +const encodeJson = (value: unknown): Effect.Effect => + Effect.try({ + try: () => { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new TypeError("Queue messages must be JSON-serializable"); + return encoded; + }, + catch: (cause) => cause, + }); + +const makeQueueDriver = ( + factory: PersistedQueue.PersistedQueueFactory["Service"], +): QueueDriverShape => { + const queues = new Map(); + + /** + * Returns the one persisted queue instance for a logical name. + * + * Producers and consumers must share an instance: a backing store can attach + * per-instance state such as poll registrations, so handing out a fresh + * instance per call leaves published messages undelivered. + */ + const queueFor = (queueName: string): Effect.Effect => + Effect.suspend(() => { + const existing = queues.get(queueName); + if (existing) return Effect.succeed(existing); + return factory.make({ name: queueName, schema: transportSchema }).pipe( + Effect.tap((queue) => Effect.sync(() => void queues.set(queueName, queue as TransportQueue))), + Effect.orDie, + ) as unknown as Effect.Effect; + }); + + const producer = ( + queueName: string, + schema: Schema.Codec, + ): QueueProducer => { + const encode = SchemaParser.encodeUnknownEffect(schema); + + const publishOne = (message: A) => + Effect.gen(function* () { + const queue = yield* queueFor(queueName); + const encoded = yield* encode(message); + const body = yield* encodeJson(encoded); + yield* queue.offer(body); + }); + + return { + publish: (message) => + PlatformRuntime.pipe( + Effect.andThen(publishOne(message)), + Effect.mapError((cause) => producerError(queueName, cause)), + ), + // The persisted store has no multi-offer primitive, so a batch is a + // sequence of offers. Publishing is idempotent per message, not atomic + // across the batch. + publishBatch: (messages) => + PlatformRuntime.pipe( + Effect.andThen(Effect.forEach(messages, publishOne, { discard: true })), + Effect.mapError((cause) => producerError(queueName, cause)), + ), + }; + }; + + /** + * Handles at most one message. + * + * The store decides ack-versus-retry from this effect's outcome: succeeding + * acknowledges, failing returns the message for another attempt. Poison + * payloads and exhausted retries therefore succeed on purpose, after being + * logged or routed to the dead-letter queue. + */ + const handleOne = ( + queueName: string, + schema: Schema.Codec, + handleBatch: (messages: ReadonlyArray) => Effect.Effect, + options: ReturnType, + claim: { claimed: boolean }, + ) => + Effect.gen(function* () { + const queue = yield* queueFor(queueName); + const decode = SchemaParser.decodeUnknownEffect(schema); + + return yield* queue.take( + (body, metadata) => + Effect.gen(function* () { + claim.claimed = true; + const parsed = yield* Effect.try({ + try: () => JSON.parse(body) as unknown, + catch: (cause) => cause, + }).pipe( + Effect.matchEffect({ + onFailure: () => Effect.succeedNone, + onSuccess: (value: unknown) => Effect.succeedSome(value), + }), + ); + + if (parsed._tag === "None") { + yield* Effect.logWarning("queue payload is not valid JSON; acking poison message", { + queueName, + messageId: metadata.id, + }); + return; + } + + const decoded = yield* decode(parsed.value).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Effect.logWarning("queue payload decode failed; acking poison message", { + queueName, + messageId: metadata.id, + cause: Cause.pretty(cause), + }).pipe(Effect.as(undefined)), + onSuccess: (message) => Effect.succeed(message), + }), + ); + if (decoded === undefined) return; + + yield* handleBatch([decoded]).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + // `attempts` counts prior failed deliveries, so it is 0 the + // first time a message is handled: a queue configured with + // zero retries dead-letters immediately. + if (metadata.attempts < options.maxRetries) { + // Hand the message back to the store for another attempt. + yield* Effect.logDebug("queue handler failed; returning message", { + queueName, + messageId: metadata.id, + cause: Cause.pretty(cause), + }); + return yield* Effect.fail(new HandlerRetry()); + } + yield* Effect.logWarning("queue message exhausted retries", { + queueName, + messageId: metadata.id, + attempts: metadata.attempts, + cause: Cause.pretty(cause), + }); + if (options.deadLetterQueue) { + const dlq = yield* queueFor(options.deadLetterQueue); + yield* dlq.offer(body); + } + }), + ), + ); + }), + // Retry accounting is this driver's job, not the store's, so the store + // is told never to give up. The bound stays inside a 32-bit integer + // because SQL stores compare it against an INT column. + { maxAttempts: 2_000_000_000 }, + ); + }); + + const processBatch = ( + queueName: string, + schema: Schema.Codec, + handleBatch: (messages: ReadonlyArray) => Effect.Effect, + inputOptions: QueueConsumerOptions | undefined, + ) => { + const options = resolvedOptions(inputOptions); + return PlatformRuntime.pipe( + Effect.andThen(Effect.sync(() => ({ claimed: false }))), + Effect.flatMap((claim) => + // `take` waits for a message, but `processBatch` must report an empty + // queue instead of blocking, so the wait is bounded by the poll + // interval. Interrupting the wait releases no lock, since nothing was + // claimed yet. + Effect.scoped(handleOne(queueName, schema, handleBatch, options, claim)).pipe( + Effect.timeoutOption(Duration.millis(options.pollIntervalMillis)), + // A failing handler is ordinary queue behaviour, not a driver + // failure: the store has already recorded the attempt by the time the + // scope closes, so the claim is reported rather than raised. Catching + // outside the scope is what lets the store see the failure at all. + // Store and SQL failures are deliberately left to propagate. + Effect.catchIf( + (error): error is HandlerRetry => + typeof error === "object" && error !== null && "_tag" in error && + (error as { _tag?: unknown })._tag === "HandlerRetry", + () => Effect.void, + ), + Effect.map(() => (claim.claimed ? 1 : 0)), + ), + ), + Effect.mapError((cause) => consumerError(queueName, cause)), + ); + }; + + return { + producer, + processBatch, + consumeBatch: (queueName, schema, handleBatch, options) => { + const resolved = resolvedOptions(options); + return Effect.forever( + Effect.suspend(() => + Effect.scoped( + handleOne(queueName, schema, handleBatch, resolved, { claimed: false }), + ).pipe( + Effect.catchCause((cause) => + Effect.logDebug("queue message returned for retry", { + queueName, + cause: Cause.pretty(cause), + }).pipe(Effect.andThen(Effect.sleep(Duration.millis(resolved.retryDelayMillis)))), + ), + ), + ), + ); + }, + } as QueueDriverShape; +}; + +/** + * Queue driver backed by Effect's persisted queue. + * + * Messages are delivered one at a time, so a consumer's batch always holds a + * single message; retries and dead-lettering are therefore per message rather + * than per batch. + * + * Note for SQL-backed stores: `processBatch` gives up after + * `pollIntervalMillis`, so that window must exceed the store's own poll + * interval, or a non-empty queue can look empty. The SQL store defaults to one + * second, which is longer than this driver's default claim window. + */ +export const ClusterQueueLive: Layer.Layer< + QueueDriver, + never, + PersistedQueue.PersistedQueueFactory +> = Layer.effect( + QueueDriver, + Effect.map(PersistedQueue.PersistedQueueFactory, makeQueueDriver), +); diff --git a/selfhost/platform-cluster/src/Topology.ts b/selfhost/platform-cluster/src/Topology.ts new file mode 100644 index 00000000..a41be1d0 --- /dev/null +++ b/selfhost/platform-cluster/src/Topology.ts @@ -0,0 +1,44 @@ +import type { Config, Layer } from "effect"; +import type { SqlClient } from "effect/unstable/sql"; +import { + MessageStorage, + type Runners, + type Sharding, + type ShardingConfig, + SingleRunner, + TestRunner, +} from "effect/unstable/cluster"; + +/** + * Cluster services every platform-cluster adapter needs in context. + * + * A topology decides how those services are backed: one process with durable + * SQL mailboxes, many processes over a network transport, or an ephemeral + * in-memory cluster for tests. Adapters never name a topology themselves, so a + * deployment can move between them without touching adapter code. + */ +export type ClusterTopology = Sharding.Sharding | Runners.Runners | MessageStorage.MessageStorage; + +/** + * Single-process cluster over the ambient SQL client. + * + * This is the default self-host topology: one runner owns every shard, while + * mailboxes, workflow state, and cron slots persist in SQL so nothing is lost + * across restarts. + */ +export const SingleNodeClusterLive = (options?: { + readonly shardingConfig?: Partial; + readonly runnerStorage?: "memory" | "sql"; +}): Layer.Layer => + SingleRunner.layer({ + shardingConfig: options?.shardingConfig, + runnerStorage: options?.runnerStorage ?? "sql", + }); + +/** + * Fully in-memory cluster with no SQL dependency, for tests and ephemeral + * local development. State does not survive the process. + */ +export const TestClusterLive: Layer.Layer< + ClusterTopology | MessageStorage.MemoryDriver +> = TestRunner.layer; diff --git a/selfhost/platform-cluster/src/Workflow.ts b/selfhost/platform-cluster/src/Workflow.ts new file mode 100644 index 00000000..e6799e7c --- /dev/null +++ b/selfhost/platform-cluster/src/Workflow.ts @@ -0,0 +1,17 @@ +import { EffectWorkflowRunnerLive } from "@voidhash/platform/EffectWorkflowRunner"; +import type { WorkflowRunner } from "@voidhash/platform/Workflow"; +import { Layer } from "effect"; +import { ClusterWorkflowEngine, type MessageStorage, type Sharding } from "effect/unstable/cluster"; + +/** + * Durable workflow runner backed by Effect Cluster. + * + * Each workflow execution becomes a cluster entity keyed by its execution ID, + * so activities, durable clocks, and suspended runs persist in the cluster's + * message storage rather than in a bespoke engine. + */ +export const ClusterWorkflowRunnerLive: Layer.Layer< + WorkflowRunner, + never, + Sharding.Sharding | MessageStorage.MessageStorage +> = EffectWorkflowRunnerLive.pipe(Layer.provide(ClusterWorkflowEngine.layer)); diff --git a/selfhost/platform-cluster/src/index.ts b/selfhost/platform-cluster/src/index.ts new file mode 100644 index 00000000..36bb9362 --- /dev/null +++ b/selfhost/platform-cluster/src/index.ts @@ -0,0 +1,9 @@ +export { ClusterCronSchedulerLive } from "./CronScheduler.ts"; +export { + ClusterDurableEntityHostLive, + makeClusterDurableEntityHost, +} from "./DurableEntity.ts"; +export { ClusterPlatformRuntimeLive } from "./PlatformRuntime.ts"; +export { ClusterQueueLive } from "./Queue.ts"; +export { type ClusterTopology, SingleNodeClusterLive, TestClusterLive } from "./Topology.ts"; +export { ClusterWorkflowRunnerLive } from "./Workflow.ts"; diff --git a/selfhost/platform-cluster/tests/SingleNodePg.integration.test.ts b/selfhost/platform-cluster/tests/SingleNodePg.integration.test.ts new file mode 100644 index 00000000..716c15ce --- /dev/null +++ b/selfhost/platform-cluster/tests/SingleNodePg.integration.test.ts @@ -0,0 +1,141 @@ +import * as PgClient from "@effect/sql-pg/PgClient"; +import { + DurableEntityHost, + makeDurableEntityAddress, +} from "@voidhash/platform/DurableEntity"; +import { QueueDriver } from "@voidhash/platform/Queue"; +import { + durableEntityHostConformance, + queueDriverConformance, + workflowRunnerConformance, +} from "@voidhash/platform/conformance"; +import { Effect, Layer, Redacted, Schema } from "effect"; +import { KeyValueStore, PersistedQueue } from "effect/unstable/persistence"; +import { describe, expect, it } from "vitest"; + +import { ClusterDurableEntityHostLive } from "../src/DurableEntity.ts"; +import { ClusterPlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import { ClusterQueueLive } from "../src/Queue.ts"; +import { SingleNodeClusterLive } from "../src/Topology.ts"; +import { ClusterWorkflowRunnerLive } from "../src/Workflow.ts"; + +/** + * Exercises the production self-host topology: one runner whose mailboxes, + * workflow state, queues, and entity state all live in Postgres. + * + * Opt in with `PLATFORM_CLUSTER_PG_TEST=1` and a reachable database, matching + * how the single-node adapter suites gate. + */ +const enabled = process.env.PLATFORM_CLUSTER_PG_TEST === "1"; + +const SqlLive = PgClient.layer({ + host: process.env.PLATFORM_CLUSTER_PG_HOST ?? "127.0.0.1", + port: Number(process.env.PLATFORM_CLUSTER_PG_PORT ?? "5432"), + database: process.env.PLATFORM_CLUSTER_PG_DATABASE ?? "voidhash", + username: process.env.PLATFORM_CLUSTER_PG_USERNAME ?? "voidhash", + password: Redacted.make(process.env.PLATFORM_CLUSTER_PG_PASSWORD ?? "password"), +}); + +// Runner storage stays in memory: a single runner owns every shard, so the +// lease table would only add schema churn to the test database. +const ClusterLive = SingleNodeClusterLive({ runnerStorage: "memory" }).pipe( + Layer.provide(SqlLive), + Layer.orDie, +); + +const workflowLayer = () => + ClusterWorkflowRunnerLive.pipe( + Layer.provide(ClusterLive), + Layer.merge(ClusterPlatformRuntimeLive), + ); + +const queueLayer = () => + ClusterQueueLive.pipe( + Layer.provide(PersistedQueue.layer), + Layer.provide( + // The SQL store polls for new rows on an interval; it must be shorter + // than the driver's claim window or `processBatch` reports an empty + // queue that is not actually empty. + PersistedQueue.layerStoreSql({ pollInterval: "50 millis" }).pipe( + Layer.provide(SqlLive), + Layer.orDie, + ), + ), + Layer.merge(ClusterPlatformRuntimeLive), + ); + +const entityLayer = () => + ClusterDurableEntityHostLive.pipe( + Layer.provide(KeyValueStore.layerSql().pipe(Layer.provide(SqlLive), Layer.orDie)), + Layer.provide(ClusterLive), + ); + +if (enabled) { + workflowRunnerConformance({ name: "cluster/postgres", layer: workflowLayer }); + queueDriverConformance({ name: "cluster/postgres", layer: queueLayer }); + durableEntityHostConformance({ name: "cluster/postgres", layer: entityLayer }); + + describe("cluster/postgres durability", () => { + it("keeps entity state across independent host instances", async () => { + const address = makeDurableEntityAddress("durability", `run-${Date.now()}`); + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + yield* host.run(address, (entity) => entity.keyValue.put("value", { survives: true })); + }).pipe(Effect.provide(entityLayer())), + ) as Effect.Effect, + ); + + // A brand-new host, and therefore a brand-new in-process lock and + // session map, must still observe the persisted value. + const restored = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + return yield* host.run(address, (entity) => entity.keyValue.get("value")); + }).pipe(Effect.provide(entityLayer())), + ) as Effect.Effect, + ); + + expect(restored).toEqual({ survives: true }); + }); + + it("keeps queued messages across independent driver instances", async () => { + const Message = Schema.Struct({ id: Schema.String }); + const queueName = `durability-${Date.now()}`; + const seen: Array = []; + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const driver = yield* QueueDriver; + yield* driver.producer(queueName, Message).publish({ id: "persisted" }); + }).pipe(Effect.provide(queueLayer())), + ) as Effect.Effect, + ); + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const driver = yield* QueueDriver; + yield* driver.processBatch( + queueName, + Message, + (messages: ReadonlyArray) => + Effect.sync(() => void seen.push(...messages.map((m) => m.id))), + { pollIntervalMillis: 500 }, + ); + }).pipe(Effect.provide(queueLayer())), + ) as Effect.Effect, + ); + + expect(seen).toEqual(["persisted"]); + }); + }); +} else { + describe.skip("cluster/postgres", () => { + it("requires PLATFORM_CLUSTER_PG_TEST=1", () => {}); + }); +} diff --git a/selfhost/platform-cluster/tests/cluster.test.ts b/selfhost/platform-cluster/tests/cluster.test.ts new file mode 100644 index 00000000..cc952a82 --- /dev/null +++ b/selfhost/platform-cluster/tests/cluster.test.ts @@ -0,0 +1,185 @@ +import { + DurableEntityHost, + makeDurableEntityAddress, +} from "@voidhash/platform/DurableEntity"; +import { QueueDriver } from "@voidhash/platform/Queue"; +import { defineWorkflow, WorkflowRunner } from "@voidhash/platform/Workflow"; +import { Effect, Layer, Schema } from "effect"; +import { KeyValueStore, PersistedQueue } from "effect/unstable/persistence"; +import { describe, expect, it } from "vitest"; + +import { ClusterDurableEntityHostLive } from "../src/DurableEntity.ts"; +import { ClusterPlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import { ClusterQueueLive } from "../src/Queue.ts"; +import { TestClusterLive } from "../src/Topology.ts"; +import { ClusterWorkflowRunnerLive } from "../src/Workflow.ts"; + +const Message = Schema.Struct({ id: Schema.String }); + +const queueLayer = ClusterQueueLive.pipe( + Layer.provide(PersistedQueue.layer), + Layer.provide(PersistedQueue.layerStoreMemory), + Layer.merge(ClusterPlatformRuntimeLive), +); + +const workflowLayer = ClusterWorkflowRunnerLive.pipe( + Layer.provide(TestClusterLive), + Layer.merge(ClusterPlatformRuntimeLive), +); + +const entityLayer = ClusterDurableEntityHostLive.pipe( + Layer.provide(KeyValueStore.layerMemory), + Layer.provide(TestClusterLive), +); + +describe("cluster queue driver", () => { + it("publishes a batch and delivers every message", async () => { + const seen: Array = []; + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const driver = yield* QueueDriver; + yield* driver + .producer("batch", Message) + .publishBatch([{ id: "a" }, { id: "b" }, { id: "c" }]); + + // The driver delivers one message per claim, so drain until empty. + yield* Effect.repeat( + driver.processBatch( + "batch", + Message, + (messages: ReadonlyArray) => + Effect.sync(() => void seen.push(...messages.map((m) => m.id))), + { pollIntervalMillis: 50 }, + ), + { until: (claimed: number) => claimed === 0 }, + ); + }).pipe(Effect.provide(queueLayer)), + ) as Effect.Effect, + ); + + expect([...seen].sort()).toEqual(["a", "b", "c"]); + }); + + it("keeps separate queues isolated", async () => { + const seen: Array = []; + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const driver = yield* QueueDriver; + yield* driver.producer("left", Message).publish({ id: "left-1" }); + + const claimed = yield* driver.processBatch( + "right", + Message, + (messages: ReadonlyArray) => + Effect.sync(() => void seen.push(...messages.map((m) => m.id))), + { pollIntervalMillis: 50 }, + ); + expect(claimed).toBe(0); + }).pipe(Effect.provide(queueLayer)), + ) as Effect.Effect, + ); + + expect(seen).toEqual([]); + }); +}); + +describe("cluster workflow runner", () => { + const Sleeper = defineWorkflow({ + name: "cluster-sleeper", + payload: { subject: Schema.String }, + success: Schema.String, + idempotencyKey: (payload) => payload.subject, + }); + + it("resumes a workflow across a durable sleep", async () => { + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + yield* runner.register(Sleeper, (payload, context) => + Effect.gen(function* () { + // Already in the past, so the durable clock resolves immediately + // instead of parking the execution. + yield* context.sleepUntil("wait", new Date(Date.now() - 1_000)); + return `woke ${payload.subject}`; + }), + ); + return yield* runner.execute(Sleeper, { subject: "up" }); + }).pipe(Effect.provide(workflowLayer)), + ) as Effect.Effect, + ); + + expect(result).toBe("woke up"); + }); + + it("memoizes a durable step so a retried body does not repeat it", async () => { + let sideEffects = 0; + + const Flaky = defineWorkflow({ + name: "cluster-flaky", + payload: { subject: Schema.String }, + success: Schema.String, + idempotencyKey: (payload) => payload.subject, + }); + + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + yield* runner.register(Flaky, (payload, context) => + Effect.gen(function* () { + const value = yield* context.step({ + name: "count", + success: Schema.Number, + execute: Effect.sync(() => ++sideEffects), + }); + return `${payload.subject}:${value}`; + }), + ); + // Executing the same idempotency key twice joins one execution. + yield* runner.execute(Flaky, { subject: "once" }); + return yield* runner.execute(Flaky, { subject: "once" }); + }).pipe(Effect.provide(workflowLayer)), + ) as Effect.Effect, + ); + + expect(result).toBe("once:1"); + expect(sideEffects).toBe(1); + }); +}); + +describe("cluster durable entity host", () => { + it("serializes interleaved read-modify-write turns", async () => { + const address = makeDurableEntityAddress("counter", "shared"); + + const total = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + + const increment = host.run(address, (entity) => + Effect.gen(function* () { + const current = yield* entity.keyValue.get("count"); + const next = (typeof current === "number" ? current : 0) + 1; + // Yield between read and write: without serialization the + // increments would clobber each other. + yield* Effect.sleep("1 millis"); + yield* entity.keyValue.put("count", next); + }), + ); + + yield* Effect.all([increment, increment, increment, increment, increment], { + concurrency: "unbounded", + }); + return yield* host.run(address, (entity) => entity.keyValue.get("count")); + }).pipe(Effect.provide(entityLayer)), + ) as Effect.Effect, + ); + + expect(total).toBe(5); + }); +}); diff --git a/selfhost/platform-cluster/tests/conformance.test.ts b/selfhost/platform-cluster/tests/conformance.test.ts new file mode 100644 index 00000000..fd7775c5 --- /dev/null +++ b/selfhost/platform-cluster/tests/conformance.test.ts @@ -0,0 +1,50 @@ +import { + cronSchedulerConformance, + durableEntityHostConformance, + queueDriverConformance, + workflowRunnerConformance, +} from "@voidhash/platform/conformance"; +import { Layer } from "effect"; +import { KeyValueStore, PersistedQueue } from "effect/unstable/persistence"; + +import { ClusterCronSchedulerLive } from "../src/CronScheduler.ts"; +import { ClusterDurableEntityHostLive } from "../src/DurableEntity.ts"; +import { ClusterPlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import { ClusterQueueLive } from "../src/Queue.ts"; +import { TestClusterLive } from "../src/Topology.ts"; +import { ClusterWorkflowRunnerLive } from "../src/Workflow.ts"; + +/** + * Each suite builds its own cluster so state cannot leak between tests. The + * in-memory topology keeps the suite hermetic — no Postgres, no network. + */ +const queueLayer = () => + ClusterQueueLive.pipe( + Layer.provide(PersistedQueue.layer), + Layer.provide(PersistedQueue.layerStoreMemory), + Layer.merge(ClusterPlatformRuntimeLive), + ); + +const cronLayer = () => + ClusterCronSchedulerLive.pipe( + Layer.provide(KeyValueStore.layerMemory), + Layer.provide(TestClusterLive), + Layer.merge(ClusterPlatformRuntimeLive), + ); + +const entityLayer = () => + ClusterDurableEntityHostLive.pipe( + Layer.provide(KeyValueStore.layerMemory), + Layer.provide(TestClusterLive), + ); + +const workflowLayer = () => + ClusterWorkflowRunnerLive.pipe( + Layer.provide(TestClusterLive), + Layer.merge(ClusterPlatformRuntimeLive), + ); + +queueDriverConformance({ name: "cluster", layer: queueLayer }); +cronSchedulerConformance({ name: "cluster", layer: cronLayer }); +durableEntityHostConformance({ name: "cluster", layer: entityLayer }); +workflowRunnerConformance({ name: "cluster", layer: workflowLayer }); diff --git a/selfhost/platform-cluster/tsconfig.json b/selfhost/platform-cluster/tsconfig.json new file mode 100644 index 00000000..f040f8ac --- /dev/null +++ b/selfhost/platform-cluster/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@voidhash/tsconfig/typescript-6.json", + "compilerOptions": { + "types": ["node"], + "noEmit": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true + }, + "include": ["src", "tests", "vitest.mts"], + "exclude": ["**/node_modules/**"] +} diff --git a/selfhost/platform-cluster/vitest.mts b/selfhost/platform-cluster/vitest.mts new file mode 100644 index 00000000..e3ed6a26 --- /dev/null +++ b/selfhost/platform-cluster/vitest.mts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + test: { + environment: "node", + include: ["./tests/**/*.test.ts"], + exclude: ["./node_modules/**"], + reporters: ["verbose"], + }, +}); diff --git a/selfhost/platform-node/package.json b/selfhost/platform-node/package.json new file mode 100644 index 00000000..077a838a --- /dev/null +++ b/selfhost/platform-node/package.json @@ -0,0 +1,42 @@ +{ + "name": "@voidhash/platform-node", + "version": "0.0.1-alpha.1", + "private": true, + "license": "AGPL-3.0-only", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./CronScheduler": "./src/CronScheduler.ts", + "./DurableEntity": "./src/DurableEntity.ts", + "./KeyValueStore": "./src/KeyValueStore.ts", + "./Mailer": "./src/Mailer.ts", + "./MemoryDurableEntity": "./src/MemoryDurableEntity.ts", + "./NodeDurableEntitySession": "./src/NodeDurableEntitySession.ts", + "./ObjectStore": "./src/ObjectStore.ts", + "./PlatformRuntime": "./src/PlatformRuntime.ts", + "./Postgres": "./src/Postgres.ts", + "./PgWorkflowEngine": "./src/PgWorkflowEngine.ts", + "./Queue": "./src/Queue.ts", + "./Screenshot": "./src/Screenshot.ts", + "./Workflow": "./src/Workflow.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run -c vitest.mts" + }, + "dependencies": { + "@effect-aws/client-s3": "catalog:", + "@effect/sql-pg": "catalog:", + "@voidhash/platform": "workspace:*", + "effect": "catalog:", + "nodemailer": "catalog:", + "playwright-core": "catalog:" + }, + "devDependencies": { + "@types/node": "^24.0.12", + "@types/nodemailer": "catalog:", + "@voidhash/tsconfig": "workspace:*", + "typescript": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/selfhost/platform-node/src/CronScheduler.ts b/selfhost/platform-node/src/CronScheduler.ts new file mode 100644 index 00000000..36868206 --- /dev/null +++ b/selfhost/platform-node/src/CronScheduler.ts @@ -0,0 +1,249 @@ +import { + type CronJob, + type CronRunOptions, + CronScheduler, + CronSchedulerError, + type CronSchedulerShape, +} from "@voidhash/platform/CronScheduler"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { Cause, Cron, Duration, Effect, Layer, Result } from "effect"; +import { SqlClient } from "effect/unstable/sql"; + +import { PgPlatformClientLive, type PgPlatformConfig } from "./Postgres.ts"; + +interface ClaimedRunRow { + readonly leaseToken: string; + readonly scheduledTime: number | string; +} + +interface ChangedRow { + readonly jobName: string; +} + +const ensureTable = (sql: SqlClient.SqlClient) => + sql.withTransaction( + Effect.gen(function* () { + yield* sql`SELECT pg_advisory_xact_lock(hashtext('voidhash_platform_cron_schema_v1'))`; + yield* sql` + CREATE TABLE IF NOT EXISTS platform_cron_state ( + job_name TEXT PRIMARY KEY, + expression TEXT NOT NULL, + time_zone TEXT, + last_scheduled_at_ms BIGINT, + next_scheduled_at_ms BIGINT NOT NULL, + lease_token TEXT, + leased_until_ms BIGINT, + updated_at_ms BIGINT NOT NULL + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS platform_cron_state_due_idx + ON platform_cron_state (next_scheduled_at_ms) + `; + }), + ); + +const schedulerError = (jobName: string, operation: string, cause: unknown) => + new CronSchedulerError({ jobName, operation, cause: String(cause) }); + +const parseCron = (job: CronJob) => { + const parsed = Cron.parse(job.expression, job.timeZone); + return Result.isSuccess(parsed) + ? Effect.succeed(parsed.success) + : Effect.fail(schedulerError(job.name, "parse", parsed.failure.message)); +}; + +const leaseMillis = (job: CronJob): number => { + const value = job.leaseMillis; + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : 300_000; +}; + +const claimRun = ( + sql: SqlClient.SqlClient, + job: CronJob, + cron: Cron.Cron, + now: number, +) => { + const initialNext = Cron.next(cron, new Date(now - 1)).getTime(); + const token = crypto.randomUUID(); + const timeZone = job.timeZone ?? null; + return sql.withTransaction( + Effect.gen(function* () { + yield* sql` + INSERT INTO platform_cron_state ( + job_name, expression, time_zone, next_scheduled_at_ms, updated_at_ms + ) VALUES ( + ${job.name}, ${job.expression}, ${timeZone}, ${initialNext}, ${now} + ) + ON CONFLICT (job_name) + DO UPDATE SET + next_scheduled_at_ms = EXCLUDED.next_scheduled_at_ms, + expression = EXCLUDED.expression, + time_zone = EXCLUDED.time_zone, + lease_token = NULL, + leased_until_ms = NULL, + updated_at_ms = EXCLUDED.updated_at_ms + WHERE platform_cron_state.expression IS DISTINCT FROM EXCLUDED.expression + OR platform_cron_state.time_zone IS DISTINCT FROM EXCLUDED.time_zone + `; + return yield* sql` + WITH candidate AS ( + SELECT job_name + FROM platform_cron_state + WHERE job_name = ${job.name} + AND next_scheduled_at_ms <= ${now} + AND (leased_until_ms IS NULL OR leased_until_ms <= ${now}) + FOR UPDATE SKIP LOCKED + ) + UPDATE platform_cron_state AS state + SET lease_token = ${token}, + leased_until_ms = ${now + leaseMillis(job)}, + updated_at_ms = ${now} + FROM candidate + WHERE state.job_name = candidate.job_name + RETURNING state.next_scheduled_at_ms AS "scheduledTime", state.lease_token AS "leaseToken" + `; + }), + ); +}; + +const releaseRun = ( + sql: SqlClient.SqlClient, + jobName: string, + leaseToken: string, + now: number, +) => + sql` + UPDATE platform_cron_state + SET lease_token = NULL, leased_until_ms = NULL, updated_at_ms = ${now} + WHERE job_name = ${jobName} AND lease_token = ${leaseToken} + `; + +const completeRun = ( + sql: SqlClient.SqlClient, + jobName: string, + leaseToken: string, + scheduledTime: number, + nextScheduledTime: number, + now: number, +) => + sql` + UPDATE platform_cron_state + SET last_scheduled_at_ms = ${scheduledTime}, + next_scheduled_at_ms = ${nextScheduledTime}, + lease_token = NULL, + leased_until_ms = NULL, + updated_at_ms = ${now} + WHERE job_name = ${jobName} AND lease_token = ${leaseToken} + RETURNING job_name AS "jobName" + `; + +const tick = ( + sql: SqlClient.SqlClient, + job: CronJob, + inputNow: Date | undefined, +): Effect.Effect => + PlatformRuntime.pipe( + Effect.andThen(parseCron(job)), + Effect.flatMap((cron) => + Effect.suspend(() => { + const now = inputNow?.getTime() ?? Date.now(); + return claimRun(sql, job, cron, now).pipe( + Effect.mapError((cause) => schedulerError(job.name, "claim", cause)), + Effect.flatMap((rows) => { + const claimed = rows[0]; + if (!claimed) return Effect.succeed(false); + const scheduledTime = Number(claimed.scheduledTime); + const nextScheduledTime = Cron.next(cron, new Date(scheduledTime)).getTime(); + return job + .run({ + scheduledTime: new Date(scheduledTime), + catchUp: scheduledTime < now, + }) + .pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + releaseRun(sql, job.name, claimed.leaseToken, Date.now()).pipe( + Effect.mapError((releaseCause) => + schedulerError(job.name, "release", releaseCause), + ), + Effect.andThen( + Effect.fail( + schedulerError(job.name, "run", Cause.pretty(cause)), + ), + ), + ), + onSuccess: () => + completeRun( + sql, + job.name, + claimed.leaseToken, + scheduledTime, + nextScheduledTime, + Date.now(), + ).pipe( + Effect.flatMap((changed) => + changed.length === 1 + ? Effect.succeed(true) + : Effect.fail( + schedulerError( + job.name, + "complete", + "cron lease expired before completion", + ), + ), + ), + Effect.mapError((cause) => + cause instanceof CronSchedulerError + ? cause + : schedulerError(job.name, "complete", cause), + ), + ), + }), + ); + }), + ); + }), + ), + ); + +const makeScheduler = (sql: SqlClient.SqlClient): CronSchedulerShape => ({ + tick: (job, now) => tick(sql, job, now), + run: (job, options: CronRunOptions = {}) => { + const pollInterval = + typeof options.pollIntervalMillis === "number" && + Number.isFinite(options.pollIntervalMillis) && + options.pollIntervalMillis > 0 + ? Math.floor(options.pollIntervalMillis) + : 1_000; + return Effect.forever( + tick(sql, job, undefined).pipe( + Effect.catch((error) => + Effect.logError("scheduled job tick failed", { + jobName: job.name, + operation: error.operation, + cause: error.cause, + }).pipe(Effect.as(false)), + ), + Effect.flatMap((ran) => + ran ? Effect.yieldNow : Effect.sleep(Duration.millis(pollInterval)), + ), + ), + ); + }, +}); + +/** Postgres-backed cron scheduler with persisted catch-up and execution leases. */ +export const PgCronSchedulerLive = ( + config: PgPlatformConfig, +): Layer.Layer => + Layer.effect( + CronScheduler, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* ensureTable(sql); + return makeScheduler(sql); + }), + ).pipe(Layer.provide(PgPlatformClientLive(config)), Layer.orDie); diff --git a/selfhost/platform-node/src/DurableEntity.ts b/selfhost/platform-node/src/DurableEntity.ts new file mode 100644 index 00000000..89f026ed --- /dev/null +++ b/selfhost/platform-node/src/DurableEntity.ts @@ -0,0 +1,256 @@ +import { + DurableEntityHost, + type DurableEntityAddress, + type DurableEntityContext, + type DurableEntityHostShape, + type DurableEntitySession, +} from "@voidhash/platform/DurableEntity"; +import { Context, Effect, Layer, Semaphore } from "effect"; +import { SqlClient } from "effect/unstable/sql"; +import { createHash } from "node:crypto"; + +import { PgPlatformClientLive, type PgPlatformConfig } from "./Postgres.ts"; + +/** Postgres connection parameters for the single-node durable entity host. */ +export type PgDurableEntityConfig = PgPlatformConfig; + +/** A persisted entity alarm ready to be dispatched by the Node scheduler. */ +export interface DueDurableEntityAlarm { + readonly address: DurableEntityAddress; + readonly scheduledTime: number; +} + +/** Adapter control plane used by the single-node alarm scheduler. */ +export interface NodeDurableEntityControlShape { + readonly listDueAlarms: ( + now: number, + limit: number, + ) => Effect.Effect>; +} + +/** Exposes persisted alarms to the single-node scheduler. */ +export class NodeDurableEntityControl extends Context.Service< + NodeDurableEntityControl, + NodeDurableEntityControlShape +>()("@voidhash/platform-node/NodeDurableEntityControl") {} + +interface EntityRuntimeState { + readonly lock: Semaphore.Semaphore; + readonly sessions: Map; +} + +interface KeyValueRow { + readonly value: unknown; +} + +interface AlarmRow { + readonly type: string; + readonly id: string; + readonly scheduledTime: number | string; +} + +const runtimeKey = (address: DurableEntityAddress): string => + `${address.type}\u0000${address.id}`; + +const schemaName = (address: DurableEntityAddress): string => + `entity_${createHash("sha256").update(runtimeKey(address)).digest("hex").slice(0, 32)}`; + +const encodeJson = (value: unknown): string => { + const encoded = JSON.stringify(value); + if (encoded === undefined) { + throw new TypeError("Durable entity values must be JSON-serializable"); + } + return encoded; +}; + +const ensureTables = (sql: SqlClient.SqlClient) => + sql.withTransaction( + Effect.gen(function* () { + // PostgreSQL's IF NOT EXISTS DDL can still race in its catalog, so host + // processes serialize this tiny bootstrap migration with an advisory lock. + yield* sql`SELECT pg_advisory_xact_lock(hashtext('voidhash_platform_entity_schema_v1'))`; + yield* sql` + CREATE TABLE IF NOT EXISTS platform_entity_kv ( + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + key TEXT NOT NULL, + value_json JSONB NOT NULL, + PRIMARY KEY (entity_type, entity_id, key) + ) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS platform_entity_alarms ( + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + scheduled_time BIGINT NOT NULL, + PRIMARY KEY (entity_type, entity_id) + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS platform_entity_alarms_due_idx + ON platform_entity_alarms (scheduled_time) + `; + }), + ); + +const makePgHost = (sql: SqlClient.SqlClient): DurableEntityHostShape => { + const runtimeStates = new Map(); + const runDb = (effect: Effect.Effect): Effect.Effect => + effect.pipe(Effect.orDie); + + const stateFor = (address: DurableEntityAddress): EntityRuntimeState => { + const key = runtimeKey(address); + let state = runtimeStates.get(key); + if (!state) { + state = { lock: Semaphore.makeUnsafe(1), sessions: new Map() }; + runtimeStates.set(key, state); + } + return state; + }; + + const contextFor = ( + address: DurableEntityAddress, + state: EntityRuntimeState, + ): DurableEntityContext => ({ + address, + keyValue: { + get: (key) => + runDb( + sql` + SELECT value_json AS "value" + FROM platform_entity_kv + WHERE entity_type = ${address.type} + AND entity_id = ${address.id} + AND key = ${key} + `.pipe(Effect.map((rows) => rows[0]?.value)), + ), + put: (key, value) => + runDb( + Effect.suspend(() => { + const encoded = encodeJson(value); + return sql` + INSERT INTO platform_entity_kv (entity_type, entity_id, key, value_json) + VALUES (${address.type}, ${address.id}, ${key}, ${encoded}::jsonb) + ON CONFLICT (entity_type, entity_id, key) + DO UPDATE SET value_json = EXCLUDED.value_json + `.pipe(Effect.asVoid); + }), + ), + delete: (key) => + runDb( + sql` + DELETE FROM platform_entity_kv + WHERE entity_type = ${address.type} + AND entity_id = ${address.id} + AND key = ${key} + `.pipe(Effect.asVoid), + ), + }, + sql: { + execute: >>( + statement: string, + bindings: ReadonlyArray = [], + ) => { + const schema = schemaName(address); + return runDb( + sql.withTransaction( + Effect.gen(function* () { + yield* sql.unsafe(`CREATE SCHEMA IF NOT EXISTS ${schema}`); + yield* sql.unsafe(`SET LOCAL search_path TO ${schema}, public`); + return yield* sql.unsafe(statement, bindings); + }), + ), + ); + }, + }, + alarm: { + get: runDb( + sql` + SELECT entity_type AS "type", entity_id AS "id", scheduled_time AS "scheduledTime" + FROM platform_entity_alarms + WHERE entity_type = ${address.type} AND entity_id = ${address.id} + `.pipe(Effect.map((rows) => (rows[0] ? Number(rows[0].scheduledTime) : undefined))), + ), + set: (scheduledTime) => + runDb( + sql` + INSERT INTO platform_entity_alarms (entity_type, entity_id, scheduled_time) + VALUES (${address.type}, ${address.id}, ${scheduledTime}) + ON CONFLICT (entity_type, entity_id) + DO UPDATE SET scheduled_time = EXCLUDED.scheduled_time + `.pipe(Effect.asVoid), + ), + delete: runDb( + sql` + DELETE FROM platform_entity_alarms + WHERE entity_type = ${address.type} AND entity_id = ${address.id} + `.pipe(Effect.asVoid), + ), + }, + sessions: { + get: (sessionId) => Effect.sync(() => state.sessions.get(sessionId)), + list: Effect.sync(() => [...state.sessions.values()]), + attach: (session) => + Effect.sync(() => void state.sessions.set(session.id, session)), + remove: (sessionId) => Effect.sync(() => void state.sessions.delete(sessionId)), + }, + }); + + return DurableEntityHost.of({ + run: (address, operation) => + Effect.suspend(() => { + const state = stateFor(address); + return state.lock.withPermit( + Effect.suspend(() => operation(contextFor(address, state))), + ); + }), + }); +}; + +const makeControl = (sql: SqlClient.SqlClient): NodeDurableEntityControlShape => ({ + listDueAlarms: (now, limit) => + sql` + SELECT entity_type AS "type", entity_id AS "id", scheduled_time AS "scheduledTime" + FROM platform_entity_alarms + WHERE scheduled_time <= ${now} + ORDER BY scheduled_time ASC, entity_type ASC, entity_id ASC + LIMIT ${Math.max(0, Math.floor(limit))} + `.pipe( + Effect.map((rows) => + rows.map((row) => ({ + address: { type: row.type, id: row.id }, + scheduledTime: Number(row.scheduledTime), + })), + ), + Effect.orDie, + ), +}); + +/** + * Postgres-backed single-node entity layer. Database state and alarms survive + * process restarts; execution locks and active WebSocket sessions are local to + * the one Node process. + */ +export const PgDurableEntityHostLive = ( + config: PgDurableEntityConfig, +): Layer.Layer => + Layer.effect( + DurableEntityHost, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* ensureTables(sql); + return makePgHost(sql); + }), + ).pipe( + Layer.merge( + Layer.effect( + NodeDurableEntityControl, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return makeControl(sql); + }), + ), + ), + Layer.provide(PgPlatformClientLive(config)), + Layer.orDie, + ); diff --git a/selfhost/platform-node/src/KeyValueStore.ts b/selfhost/platform-node/src/KeyValueStore.ts new file mode 100644 index 00000000..af3f3d36 --- /dev/null +++ b/selfhost/platform-node/src/KeyValueStore.ts @@ -0,0 +1,252 @@ +import { + type KeyValuePutOptions, + KeyValueStore, + KeyValueStoreError, + type KeyValueStoreShape, +} from "@voidhash/platform/KeyValueStore"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { Effect, Layer, Option, SchemaParser } from "effect"; +import { SqlClient } from "effect/unstable/sql"; + +import { PgPlatformClientLive, type PgPlatformConfig } from "./Postgres.ts"; + +interface ValueRow { + readonly value: unknown; +} + +interface KeyRow { + readonly key: string; +} + +const ensureTable = (sql: SqlClient.SqlClient) => + sql.withTransaction( + Effect.gen(function* () { + yield* sql`SELECT pg_advisory_xact_lock(hashtext('voidhash_platform_kv_schema_v1'))`; + yield* sql` + CREATE TABLE IF NOT EXISTS platform_key_value ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value_json JSONB NOT NULL, + expires_at_ms BIGINT, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (namespace, key) + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS platform_key_value_expiry_idx + ON platform_key_value (expires_at_ms) + WHERE expires_at_ms IS NOT NULL + `; + }), + ); + +const expiry = (now: number, options: KeyValuePutOptions | undefined): number | null => { + const ttl = options?.ttlMillis; + return typeof ttl === "number" && Number.isFinite(ttl) && ttl > 0 + ? now + Math.floor(ttl) + : null; +}; + +const encodeJson = (value: unknown): Effect.Effect => + Effect.try({ + try: () => { + const encoded = JSON.stringify(value); + if (encoded === undefined) { + throw new TypeError("Key-value entries must be JSON-serializable"); + } + return encoded; + }, + catch: (cause) => cause, + }); + +const storeError = (namespace: string, operation: string, cause: unknown) => + new KeyValueStoreError({ namespace, operation, cause: String(cause) }); + +const makeStore = (sql: SqlClient.SqlClient): KeyValueStoreShape => ({ + get: (namespace, key, schema) => + PlatformRuntime.pipe( + Effect.andThen( + Effect.suspend(() => { + const now = Date.now(); + return sql` + SELECT value_json AS "value" + FROM platform_key_value + WHERE namespace = ${namespace} + AND key = ${key} + AND (expires_at_ms IS NULL OR expires_at_ms > ${now}) + `; + }), + ), + Effect.flatMap((rows) => + rows[0] + ? SchemaParser.decodeUnknownEffect(schema)(rows[0].value).pipe(Effect.map(Option.some)) + : Effect.succeedNone, + ), + Effect.mapError((cause) => storeError(namespace, "get", cause)), + ), + put: (namespace, key, value, schema, options) => + PlatformRuntime.pipe( + Effect.andThen(SchemaParser.encodeUnknownEffect(schema)(value)), + Effect.flatMap(encodeJson), + Effect.flatMap((encoded) => { + const now = Date.now(); + const expiresAt = expiry(now, options); + return sql` + INSERT INTO platform_key_value ( + namespace, key, value_json, expires_at_ms, updated_at_ms + ) VALUES ( + ${namespace}, ${key}, ${encoded}::jsonb, ${expiresAt}, ${now} + ) + ON CONFLICT (namespace, key) + DO UPDATE SET value_json = EXCLUDED.value_json, + expires_at_ms = EXCLUDED.expires_at_ms, + updated_at_ms = EXCLUDED.updated_at_ms + `; + }), + Effect.asVoid, + Effect.mapError((cause) => storeError(namespace, "put", cause)), + ), + putMany: (namespace, entries, schema, options) => + PlatformRuntime.pipe( + Effect.andThen( + Effect.forEach(entries, ({ key, value }) => + SchemaParser.encodeUnknownEffect(schema)(value).pipe( + Effect.flatMap(encodeJson), + Effect.map((encoded) => ({ key, encoded })), + ), + ), + ), + Effect.flatMap((encodedEntries) => { + const now = Date.now(); + const expiresAt = expiry(now, options); + return sql.withTransaction( + Effect.forEach( + encodedEntries, + ({ key, encoded }) => + sql` + INSERT INTO platform_key_value ( + namespace, key, value_json, expires_at_ms, updated_at_ms + ) VALUES ( + ${namespace}, ${key}, ${encoded}::jsonb, ${expiresAt}, ${now} + ) + ON CONFLICT (namespace, key) + DO UPDATE SET value_json = EXCLUDED.value_json, + expires_at_ms = EXCLUDED.expires_at_ms, + updated_at_ms = EXCLUDED.updated_at_ms + `, + { discard: true }, + ), + ); + }), + Effect.mapError((cause) => storeError(namespace, "putMany", cause)), + ), + existingKeys: (namespace, keys) => { + if (keys.length === 0) return Effect.succeed(new Set()); + return PlatformRuntime.pipe( + Effect.andThen( + Effect.suspend(() => { + const now = Date.now(); + return sql` + SELECT key + FROM platform_key_value + WHERE namespace = ${namespace} + AND ${sql.in("key", keys)} + AND (expires_at_ms IS NULL OR expires_at_ms > ${now}) + `; + }), + ), + Effect.map((rows) => new Set(rows.map(({ key }) => key))), + Effect.mapError((cause) => storeError(namespace, "existingKeys", cause)), + ); + }, + delete: (namespace, key) => + PlatformRuntime.pipe( + Effect.andThen(sql`DELETE FROM platform_key_value WHERE namespace = ${namespace} AND key = ${key}`), + Effect.asVoid, + Effect.mapError((cause) => storeError(namespace, "delete", cause)), + ), + deleteMany: (namespace, keys) => { + if (keys.length === 0) return Effect.void; + return PlatformRuntime.pipe( + Effect.andThen( + sql` + DELETE FROM platform_key_value + WHERE namespace = ${namespace} AND ${sql.in("key", keys)} + `, + ), + Effect.asVoid, + Effect.mapError((cause) => storeError(namespace, "deleteMany", cause)), + ); + }, + increment: (namespace, key, options) => + PlatformRuntime.pipe( + Effect.andThen( + Effect.suspend(() => { + const now = Date.now(); + const expiresAt = expiry(now, options); + return sql` + INSERT INTO platform_key_value ( + namespace, key, value_json, expires_at_ms, updated_at_ms + ) VALUES ( + ${namespace}, ${key}, '1'::jsonb, ${expiresAt}, ${now} + ) + ON CONFLICT (namespace, key) + DO UPDATE SET + value_json = to_jsonb( + CASE + WHEN platform_key_value.expires_at_ms IS NOT NULL + AND platform_key_value.expires_at_ms <= ${now} + THEN 1 + WHEN jsonb_typeof(platform_key_value.value_json) = 'number' + THEN (platform_key_value.value_json #>> '{}')::BIGINT + 1 + ELSE 1 + END + ), + expires_at_ms = EXCLUDED.expires_at_ms, + updated_at_ms = EXCLUDED.updated_at_ms + RETURNING value_json AS "value" + `; + }), + ), + Effect.map((rows) => Number(rows[0]?.value ?? 0)), + Effect.mapError((cause) => storeError(namespace, "increment", cause)), + ), + pruneExpired: (limit) => + PlatformRuntime.pipe( + Effect.andThen( + Effect.suspend(() => { + const now = Date.now(); + const rowLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 0; + return sql` + WITH expired AS ( + SELECT namespace, key + FROM platform_key_value + WHERE expires_at_ms IS NOT NULL AND expires_at_ms <= ${now} + ORDER BY expires_at_ms ASC + LIMIT ${rowLimit} + FOR UPDATE SKIP LOCKED + ) + DELETE FROM platform_key_value AS value + USING expired + WHERE value.namespace = expired.namespace AND value.key = expired.key + RETURNING value.key + `; + }), + ), + Effect.map((rows) => rows.length), + Effect.mapError((cause) => storeError("*", "pruneExpired", cause)), + ), +}); + +/** Postgres-backed typed key-value store with TTL and atomic counters. */ +export const PgKeyValueStoreLive = ( + config: PgPlatformConfig, +): Layer.Layer => + Layer.effect( + KeyValueStore, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* ensureTable(sql); + return makeStore(sql); + }), + ).pipe(Layer.provide(PgPlatformClientLive(config)), Layer.orDie); diff --git a/selfhost/platform-node/src/Mailer.ts b/selfhost/platform-node/src/Mailer.ts new file mode 100644 index 00000000..d4799904 --- /dev/null +++ b/selfhost/platform-node/src/Mailer.ts @@ -0,0 +1,144 @@ +import { + type MailAddress, + type MailDeliveryResult, + Mailer, + MailerError, + type MailerShape, + type MailMessage, +} from "@voidhash/platform/Mailer"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { Effect, Layer, Redacted } from "effect"; +import nodemailer, { type Transporter } from "nodemailer"; + +/** SMTP connection, authentication, and sender defaults. */ +export interface SmtpMailerConfig { + readonly host: string; + readonly port: number; + readonly secure?: boolean; + readonly requireTls?: boolean; + readonly username?: string; + readonly password?: Redacted.Redacted; + readonly defaultFrom?: MailAddress; + readonly connectionTimeoutMillis?: number; + readonly greetingTimeoutMillis?: number; + readonly tlsRejectUnauthorized?: boolean; + readonly verifyOnStart?: boolean; +} + +const mailerError = (operation: string, cause: unknown) => + new MailerError({ operation, cause: String(cause) }); + +const mailbox = ({ address, name }: MailAddress) => ({ address, name }); + +const resultAddress = (value: unknown): string => { + if (typeof value === "string") return value; + if (typeof value === "object" && value !== null && "address" in value) { + const address = (value as { readonly address?: unknown }).address; + if (typeof address === "string") return address; + } + return String(value); +}; + +const validateMessage = ( + config: SmtpMailerConfig, + message: MailMessage, +): Effect.Effect => { + const from = message.from ?? config.defaultFrom; + if (!from) return Effect.fail(mailerError("validate", "sender is required")); + if (message.to.length === 0) { + return Effect.fail(mailerError("validate", "at least one recipient is required")); + } + if (message.text === undefined && message.html === undefined) { + return Effect.fail(mailerError("validate", "text or html content is required")); + } + return Effect.succeed(from); +}; + +const send = ( + transporter: Transporter, + config: SmtpMailerConfig, + message: MailMessage, +): Effect.Effect => + PlatformRuntime.pipe( + Effect.andThen(validateMessage(config, message)), + Effect.flatMap((from) => + Effect.tryPromise({ + try: () => + transporter.sendMail({ + from: mailbox(from), + to: message.to.map(mailbox), + cc: message.cc?.map(mailbox), + bcc: message.bcc?.map(mailbox), + replyTo: message.replyTo ? mailbox(message.replyTo) : undefined, + subject: message.subject, + text: message.text, + html: message.html, + headers: message.headers, + }), + catch: (cause) => mailerError("send", cause), + }), + ), + Effect.map((info) => ({ + messageId: info.messageId, + accepted: (info.accepted as ReadonlyArray).map(resultAddress), + rejected: (info.rejected as ReadonlyArray).map(resultAddress), + })), + ); + +const makeMailer = ( + transporter: Transporter, + config: SmtpMailerConfig, +): MailerShape => ({ + send: (message) => send(transporter, config, message), +}); + +const makeTransporter = ( + config: SmtpMailerConfig, +): Effect.Effect => { + if ((config.username === undefined) !== (config.password === undefined)) { + return Effect.fail( + mailerError("configure", "SMTP username and password must be provided together"), + ); + } + return Effect.succeed( + nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure ?? false, + requireTLS: config.requireTls ?? false, + auth: + config.username && config.password + ? { + user: config.username, + pass: Redacted.value(config.password), + } + : undefined, + connectionTimeout: config.connectionTimeoutMillis ?? 10_000, + greetingTimeout: config.greetingTimeoutMillis ?? 10_000, + tls: { + rejectUnauthorized: config.tlsRejectUnauthorized ?? true, + }, + }), + ); +}; + +/** SMTP-backed mailer with optional authenticated TLS and startup verification. */ +export const SmtpMailerLive = ( + config: SmtpMailerConfig, +): Layer.Layer => + Layer.effect( + Mailer, + Effect.acquireRelease( + makeTransporter(config).pipe( + Effect.tap((transporter) => + config.verifyOnStart + ? Effect.tryPromise({ + try: () => transporter.verify(), + catch: (cause) => mailerError("verify", cause), + }).pipe(Effect.asVoid) + : Effect.void, + ), + ), + (transporter) => Effect.sync(() => transporter.close()), + ).pipe(Effect.map((transporter) => makeMailer(transporter, config))), + ); diff --git a/selfhost/platform-node/src/MemoryDurableEntity.ts b/selfhost/platform-node/src/MemoryDurableEntity.ts new file mode 100644 index 00000000..4e69ee10 --- /dev/null +++ b/selfhost/platform-node/src/MemoryDurableEntity.ts @@ -0,0 +1,73 @@ +import { + DurableEntityHost, + type DurableEntityContext, + type DurableEntityHostShape, + type DurableEntitySession, +} from "@voidhash/platform/DurableEntity"; +import { Effect, Layer, Semaphore } from "effect"; + +interface MemoryEntityState { + readonly lock: Semaphore.Semaphore; + readonly values: Map; + readonly sessions: Map; + alarm: number | undefined; +} + +const entityKey = (type: string, id: string): string => `${type}\u0000${id}`; + +/** + * Builds an isolated in-memory durable entity host. Operations for one address + * are FIFO-serialized; different addresses may run concurrently. + */ +export const makeMemoryDurableEntityHost = (): DurableEntityHostShape => { + const states = new Map(); + + const stateFor = (type: string, id: string): MemoryEntityState => { + const key = entityKey(type, id); + let state = states.get(key); + if (!state) { + state = { + lock: Semaphore.makeUnsafe(1), + values: new Map(), + sessions: new Map(), + alarm: undefined, + }; + states.set(key, state); + } + return state; + }; + + return DurableEntityHost.of({ + run: (address, operation) => + Effect.suspend(() => { + const state = stateFor(address.type, address.id); + const context: DurableEntityContext = { + address, + keyValue: { + get: (key) => Effect.sync(() => state.values.get(key)), + put: (key, value) => Effect.sync(() => void state.values.set(key, value)), + delete: (key) => Effect.sync(() => void state.values.delete(key)), + }, + alarm: { + get: Effect.sync(() => state.alarm), + set: (scheduledTime) => Effect.sync(() => void (state.alarm = scheduledTime)), + delete: Effect.sync(() => void (state.alarm = undefined)), + }, + sessions: { + get: (sessionId) => Effect.sync(() => state.sessions.get(sessionId)), + list: Effect.sync(() => [...state.sessions.values()]), + attach: (session) => + Effect.sync(() => void state.sessions.set(session.id, session)), + remove: (sessionId) => Effect.sync(() => void state.sessions.delete(sessionId)), + }, + }; + return state.lock.withPermit(Effect.suspend(() => operation(context))); + }), + }); +}; + +/** In-memory entity host layer for tests and ephemeral local development. */ +export const MemoryDurableEntityHostLive: Layer.Layer = Layer.sync( + DurableEntityHost, + makeMemoryDurableEntityHost, +); diff --git a/selfhost/platform-node/src/NodeDurableEntitySession.ts b/selfhost/platform-node/src/NodeDurableEntitySession.ts new file mode 100644 index 00000000..5a2493ea --- /dev/null +++ b/selfhost/platform-node/src/NodeDurableEntitySession.ts @@ -0,0 +1,36 @@ +import type { DurableEntitySession } from "@voidhash/platform/DurableEntity"; +import { Effect } from "effect"; + +/** Minimal server-side WebSocket surface needed by the entity session adapter. */ +export interface NodeWebSocketLike { + readonly send: (message: string | Uint8Array) => unknown; + readonly close: (code?: number, reason?: string) => unknown; +} + +/** + * Wraps a Node WebSocket connection as a runtime-neutral durable entity + * session. Attachments remain in memory for the connection lifetime. + */ +export const makeNodeDurableEntitySession = ( + id: string, + socket: NodeWebSocketLike, + initialAttachment?: unknown, +): DurableEntitySession => { + let attachment = initialAttachment; + return { + id, + send: (message) => + Effect.sync(() => { + socket.send(message); + }), + close: (code, reason) => + Effect.sync(() => { + socket.close(code, reason); + }), + getAttachment: Effect.sync(() => attachment), + setAttachment: (nextAttachment) => + Effect.sync(() => { + attachment = nextAttachment; + }), + }; +}; diff --git a/selfhost/platform-node/src/ObjectStore.ts b/selfhost/platform-node/src/ObjectStore.ts new file mode 100644 index 00000000..104db594 --- /dev/null +++ b/selfhost/platform-node/src/ObjectStore.ts @@ -0,0 +1,142 @@ +import { S3 } from "@effect-aws/client-s3"; +import { + ObjectStore, + ObjectStoreError, + type ObjectStoreShape, +} from "@voidhash/platform/ObjectStore"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { Effect, Layer, Option, Redacted } from "effect"; + +/** S3-compatible connection and bucket parameters. */ +export interface S3ObjectStoreConfig { + readonly bucketName: string; + readonly region: string; + readonly endpoint?: string; + readonly accessKeyId: string; + readonly secretAccessKey: Redacted.Redacted; + readonly forcePathStyle?: boolean; +} + +const storeError = ( + config: S3ObjectStoreConfig, + key: string, + operation: string, + cause: unknown, +) => + new ObjectStoreError({ + bucketName: config.bucketName, + key, + operation, + cause: String(cause), + }); + +const isNotFound = (error: unknown): boolean => { + if (typeof error !== "object" || error === null) return false; + const tagged = error as { readonly _tag?: unknown }; + return tagged._tag === "NoSuchKey" || tagged._tag === "NotFound"; +}; + +const makeStore = ( + config: S3ObjectStoreConfig, + client: S3.Type, +): ObjectStoreShape => ({ + bucketName: config.bucketName, + put: ({ key, body, contentType, cacheControl }) => + PlatformRuntime.pipe( + Effect.andThen( + client.putObject({ + Bucket: config.bucketName, + Key: key, + Body: body, + ContentType: contentType, + CacheControl: cacheControl, + }), + ), + Effect.asVoid, + Effect.mapError((cause) => storeError(config, key, "put", cause)), + ), + get: (key) => + PlatformRuntime.pipe( + Effect.andThen(client.getObject({ Bucket: config.bucketName, Key: key })), + Effect.flatMap((output) => { + const stream = output.Body; + if (!stream) { + return Effect.fail(storeError(config, key, "get", "response body is missing")); + } + return Effect.tryPromise({ + try: () => stream.transformToByteArray(), + catch: (cause) => storeError(config, key, "get", cause), + }).pipe( + Effect.map((body) => + Option.some({ + body, + contentType: output.ContentType ?? null, + etag: output.ETag ?? null, + size: output.ContentLength ?? body.byteLength, + }), + ), + ); + }), + Effect.catch((cause) => + isNotFound(cause) + ? Effect.succeedNone + : Effect.fail( + cause instanceof ObjectStoreError + ? cause + : storeError(config, key, "get", cause), + ), + ), + ), + head: (key) => + PlatformRuntime.pipe( + Effect.andThen(client.headObject({ Bucket: config.bucketName, Key: key })), + Effect.map((output) => + Option.some({ + contentType: output.ContentType ?? null, + etag: output.ETag ?? null, + size: output.ContentLength ?? 0, + }), + ), + Effect.catch((cause) => + isNotFound(cause) + ? Effect.succeedNone + : Effect.fail(storeError(config, key, "head", cause)), + ), + ), + delete: (key) => + PlatformRuntime.pipe( + Effect.andThen(client.deleteObject({ Bucket: config.bucketName, Key: key })), + Effect.asVoid, + Effect.mapError((cause) => storeError(config, key, "delete", cause)), + ), +}); + +/** S3-compatible object store layer for AWS S3, MinIO, Garage, or R2. */ +export const S3ObjectStoreLive = ( + config: S3ObjectStoreConfig, +): Layer.Layer => + Layer.effect( + ObjectStore, + Effect.map(S3, (client) => makeStore(config, client)), + ).pipe( + Layer.provide( + S3.layer({ + region: config.region, + endpoint: config.endpoint, + forcePathStyle: config.forcePathStyle ?? config.endpoint !== undefined, + // AWS SDK v3 enables CRC32 checksums by default. Some S3-compatible + // stores reject those optional headers, so custom endpoints use the + // compatibility mode while native AWS S3 retains its stronger default. + ...(config.endpoint === undefined + ? {} + : { + requestChecksumCalculation: "WHEN_REQUIRED" as const, + responseChecksumValidation: "WHEN_REQUIRED" as const, + }), + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: Redacted.value(config.secretAccessKey), + }, + }), + ), + ); diff --git a/selfhost/platform-node/src/PgWorkflowEngine.ts b/selfhost/platform-node/src/PgWorkflowEngine.ts new file mode 100644 index 00000000..b0785334 --- /dev/null +++ b/selfhost/platform-node/src/PgWorkflowEngine.ts @@ -0,0 +1,710 @@ +import { Duration, Effect, Exit, Fiber, Layer, Option, Schema, Scope } from "effect"; +import { SqlClient } from "effect/unstable/sql"; +import { Workflow, WorkflowEngine } from "effect/unstable/workflow"; + +import { PgPlatformClientLive, type PgPlatformConfig } from "./Postgres.ts"; + +interface RegisteredWorkflow { + readonly workflow: Workflow.Any; + readonly execute: ( + payload: object, + executionId: string, + ) => Effect.Effect; + readonly scope: Scope.Scope; +} + +interface ClaimedExecutionRow { + readonly executionId: string; + readonly interrupted: boolean; + readonly leaseToken: string; + readonly parentExecutionId: string | null; + readonly payload: unknown; + readonly workflowName: string; +} + +interface ExecutionResultRow { + readonly result: unknown; +} + +interface ExecutionIdRow { + readonly executionId: string; +} + +interface ActivityResultRow { + readonly result: unknown; +} + +interface DeferredResultRow { + readonly exit: unknown; +} + +interface DueClockRow { + readonly deferredName: string; + readonly executionId: string; + readonly exit: unknown; + readonly workflowName: string; +} + +interface ActiveExecution { + readonly instance: WorkflowEngine.WorkflowInstance["Service"]; + interrupt: Effect.Effect; +} + +const activityResultCodec = Schema.toCodecJson( + Workflow.Result({ success: Schema.Any, error: Schema.Any }), +); + +type JsonCodec = Schema.Codec; + +const jsonCodec = (schema: Schema.Top): JsonCodec => + Schema.toCodecJson(schema) as unknown as JsonCodec; + +const encodeJson = (value: unknown): Effect.Effect => + Effect.try({ + try: () => { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new TypeError("Workflow state must be JSON-serializable"); + return encoded; + }, + catch: (cause) => cause, + }); + +const ensureTables = (sql: SqlClient.SqlClient) => + sql.withTransaction( + Effect.gen(function* () { + yield* sql`SELECT pg_advisory_xact_lock(hashtext('voidhash_platform_workflow_schema_v1'))`; + yield* sql` + CREATE TABLE IF NOT EXISTS platform_workflow_execution ( + execution_id TEXT PRIMARY KEY, + workflow_name TEXT NOT NULL, + payload_json JSONB NOT NULL, + parent_execution_id TEXT, + status TEXT NOT NULL, + result_json JSONB, + interrupted BOOLEAN NOT NULL DEFAULT FALSE, + resume_requested BOOLEAN NOT NULL DEFAULT FALSE, + lease_token TEXT, + leased_until_ms BIGINT, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL + ) + `; + yield* sql` + ALTER TABLE platform_workflow_execution + ADD COLUMN IF NOT EXISTS resume_requested BOOLEAN NOT NULL DEFAULT FALSE + `; + yield* sql` + CREATE INDEX IF NOT EXISTS platform_workflow_execution_claim_idx + ON platform_workflow_execution (status, leased_until_ms, updated_at_ms) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS platform_workflow_activity ( + execution_id TEXT NOT NULL, + activity_name TEXT NOT NULL, + attempt INTEGER NOT NULL, + result_json JSONB NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (execution_id, activity_name, attempt) + ) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS platform_workflow_deferred ( + execution_id TEXT NOT NULL, + deferred_name TEXT NOT NULL, + exit_json JSONB NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (execution_id, deferred_name) + ) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS platform_workflow_clock ( + execution_id TEXT NOT NULL, + workflow_name TEXT NOT NULL, + deferred_name TEXT NOT NULL, + due_at_ms BIGINT NOT NULL, + exit_json JSONB NOT NULL, + created_at_ms BIGINT NOT NULL, + PRIMARY KEY (execution_id, deferred_name) + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS platform_workflow_clock_due_idx + ON platform_workflow_clock (due_at_ms) + `; + }), + ); + +const workflowResultCodec = (workflow: Workflow.Any) => + Schema.toCodecJson( + Workflow.Result({ + success: workflow.successSchema, + error: workflow.errorSchema, + }), + ) as unknown as JsonCodec; + +const encodePayload = (workflow: Workflow.Any, payload: object) => + Schema.encodeEffect(jsonCodec(workflow.payloadSchema))(payload).pipe( + Effect.flatMap(encodeJson), + ); + +const decodePayload = (workflow: Workflow.Any, payload: unknown) => + Schema.decodeEffect(jsonCodec(workflow.payloadSchema))(payload as Schema.Json); + +const encodeWorkflowResult = ( + workflow: Workflow.Any, + result: Workflow.Result, +) => Schema.encodeEffect(workflowResultCodec(workflow))(result).pipe(Effect.flatMap(encodeJson)); + +const decodeWorkflowResult = (workflow: Workflow.Any, result: unknown) => + Schema.decodeEffect(workflowResultCodec(workflow))(result as Schema.Json) as Effect.Effect< + Workflow.Result + >; + +const encodeActivityResult = (result: Workflow.Result) => + Schema.encodeEffect(activityResultCodec)(result).pipe(Effect.flatMap(encodeJson)); + +const decodeActivityResult = (result: unknown) => + Schema.decodeEffect(activityResultCodec)(result as Schema.Json); + +const makePgEngine = (sql: SqlClient.SqlClient) => + Effect.gen(function* () { + const scope = yield* Effect.scope; + const registered = new Map(); + const active = new Map(); + const leaseMillis = 30_000; + + let engine: WorkflowEngine.WorkflowEngine["Service"]; + + const claimExecution = (executionId: string) => + Effect.suspend(() => { + const now = Date.now(); + const token = crypto.randomUUID(); + return sql` + WITH candidate AS ( + SELECT execution_id + FROM platform_workflow_execution + WHERE execution_id = ${executionId} + AND status IN ('pending', 'running') + AND (leased_until_ms IS NULL OR leased_until_ms <= ${now}) + FOR UPDATE SKIP LOCKED + ) + UPDATE platform_workflow_execution AS execution + SET status = 'running', + result_json = NULL, + resume_requested = FALSE, + lease_token = ${token}, + leased_until_ms = ${now + leaseMillis}, + updated_at_ms = ${now} + FROM candidate + WHERE execution.execution_id = candidate.execution_id + RETURNING execution.execution_id AS "executionId", + execution.workflow_name AS "workflowName", + execution.payload_json AS "payload", + execution.parent_execution_id AS "parentExecutionId", + execution.interrupted, + execution.lease_token AS "leaseToken" + `; + }); + + const releaseUnregistered = (executionId: string, leaseToken: string) => + Effect.suspend(() => { + const now = Date.now(); + return sql` + UPDATE platform_workflow_execution + SET status = 'pending', lease_token = NULL, leased_until_ms = NULL, updated_at_ms = ${now} + WHERE execution_id = ${executionId} AND lease_token = ${leaseToken} + `; + }); + + const releaseFailedExecution = (executionId: string, leaseToken: string) => + Effect.suspend(() => { + const now = Date.now(); + return sql` + UPDATE platform_workflow_execution + SET status = 'pending', lease_token = NULL, leased_until_ms = NULL, updated_at_ms = ${now} + WHERE execution_id = ${executionId} AND lease_token = ${leaseToken} + `; + }); + + const persistExecutionResult = ( + row: ClaimedExecutionRow, + workflow: Workflow.Any, + result: Workflow.Result, + ) => + Effect.gen(function* () { + const encoded = yield* encodeWorkflowResult(workflow, result); + const now = Date.now(); + const suspended = result._tag === "Suspended"; + const changed = yield* sql` + UPDATE platform_workflow_execution + SET status = CASE + WHEN ${suspended} AND resume_requested THEN 'pending' + ELSE ${suspended ? "suspended" : "complete"} + END, + result_json = CASE + WHEN ${suspended} AND resume_requested THEN NULL + ELSE ${encoded}::jsonb + END, + resume_requested = FALSE, + lease_token = NULL, + leased_until_ms = NULL, + updated_at_ms = ${now} + WHERE execution_id = ${row.executionId} AND lease_token = ${row.leaseToken} + RETURNING execution_id AS "executionId" + `; + return changed.length === 1; + }); + + const heartbeatLease = (row: ClaimedExecutionRow) => + Effect.forever( + Effect.sleep(leaseMillis / 3).pipe( + Effect.andThen( + Effect.suspend(() => { + const now = Date.now(); + return sql` + UPDATE platform_workflow_execution + SET leased_until_ms = ${now + leaseMillis}, updated_at_ms = ${now} + WHERE execution_id = ${row.executionId} + AND lease_token = ${row.leaseToken} + AND status = 'running' + `; + }), + ), + Effect.catchCause((cause) => + Effect.logError("workflow execution lease heartbeat failed", { + executionId: row.executionId, + workflowName: row.workflowName, + cause: String(cause), + }), + ), + ), + ); + + const runClaimedExecution = ( + row: ClaimedExecutionRow, + entry: RegisteredWorkflow, + instance: WorkflowEngine.WorkflowInstance["Service"], + ) => + Effect.scoped(Effect.gen(function* () { + yield* heartbeatLease(row).pipe(Effect.forkScoped); + const payload = yield* decodePayload(entry.workflow, row.payload); + const execution = instance.interrupted + ? Effect.interrupt + : entry.execute(payload as object, row.executionId); + const result = yield* execution.pipe( + Workflow.intoResult, + Effect.provideService(WorkflowEngine.WorkflowInstance, instance), + Effect.provideService(WorkflowEngine.WorkflowEngine, engine), + ); + const persisted = yield* persistExecutionResult(row, entry.workflow, result); + if (persisted && result._tag === "Complete" && row.parentExecutionId) { + yield* sql` + UPDATE platform_workflow_execution + SET resume_requested = TRUE, + status = CASE WHEN status = 'suspended' THEN 'pending' ELSE status END, + result_json = CASE WHEN status = 'suspended' THEN NULL ELSE result_json END, + lease_token = CASE WHEN status = 'suspended' THEN NULL ELSE lease_token END, + leased_until_ms = CASE + WHEN status = 'suspended' THEN NULL + ELSE leased_until_ms + END, + updated_at_ms = ${Date.now()} + WHERE execution_id = ${row.parentExecutionId} + AND status IN ('running', 'suspended') + `; + yield* startExecution(row.parentExecutionId); + } + })).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? releaseFailedExecution(row.executionId, row.leaseToken).pipe( + Effect.tap(() => + Effect.logError("workflow execution failed before producing a result", { + executionId: row.executionId, + workflowName: row.workflowName, + cause: String(exit.cause), + }), + ), + ) + : Effect.void, + ), + ); + + function startExecution(executionId: string): Effect.Effect { + if (active.has(executionId)) return Effect.void; + return Effect.gen(function* () { + if (active.has(executionId)) return; + const rows = yield* claimExecution(executionId); + const row = rows[0]; + if (!row) return; + const entry = registered.get(row.workflowName); + if (!entry) { + yield* releaseUnregistered(executionId, row.leaseToken); + return; + } + + const instance = WorkflowEngine.WorkflowInstance.initial( + entry.workflow, + executionId, + ); + instance.interrupted = row.interrupted; + const activeExecution: ActiveExecution = { instance, interrupt: Effect.void }; + active.set(executionId, activeExecution); + const fiber = yield* runClaimedExecution(row, entry, instance).pipe( + Effect.ensuring( + Effect.sync(() => { + active.delete(executionId); + }), + ), + Effect.forkIn(entry.scope), + ); + activeExecution.interrupt = Fiber.interrupt(fiber); + }); + } + + const pollResult = (workflow: Workflow.Any, executionId: string) => + sql` + SELECT result_json AS "result" + FROM platform_workflow_execution + WHERE execution_id = ${executionId} AND result_json IS NOT NULL + `.pipe( + Effect.flatMap((rows) => + rows[0] + ? decodeWorkflowResult(workflow, rows[0].result).pipe(Effect.map(Option.some)) + : Effect.succeedNone, + ), + ); + + function waitForResult( + workflow: Workflow.Any, + executionId: string, + ): Effect.Effect, unknown> { + return pollResult(workflow, executionId).pipe( + Effect.flatMap((result) => + Option.isSome(result) + ? Effect.succeed(result.value) + : Effect.sleep("20 millis").pipe( + Effect.andThen(waitForResult(workflow, executionId)), + ), + ), + ); + } + + const ensureExecution = ( + workflow: Workflow.Any, + executionId: string, + payload: object, + parent: WorkflowEngine.WorkflowInstance["Service"] | undefined, + ) => + Effect.gen(function* () { + const encoded = yield* encodePayload(workflow, payload); + const now = Date.now(); + yield* sql` + INSERT INTO platform_workflow_execution ( + execution_id, workflow_name, payload_json, parent_execution_id, + status, created_at_ms, updated_at_ms + ) VALUES ( + ${executionId}, ${workflow._tag}, ${encoded}::jsonb, + ${parent?.executionId ?? null}, 'pending', ${now}, ${now} + ) + ON CONFLICT (execution_id) DO NOTHING + `; + }); + + const processDueClocks = Effect.gen(function* () { + const now = Date.now(); + const rows = yield* sql.withTransaction( + Effect.gen(function* () { + const due = yield* sql` + SELECT execution_id AS "executionId", workflow_name AS "workflowName", + deferred_name AS "deferredName", exit_json AS "exit" + FROM platform_workflow_clock + WHERE due_at_ms <= ${now} + ORDER BY due_at_ms ASC + LIMIT 50 + FOR UPDATE SKIP LOCKED + `; + yield* Effect.forEach( + due, + (clock) => + Effect.gen(function* () { + const encoded = yield* encodeJson(clock.exit); + yield* sql` + INSERT INTO platform_workflow_deferred ( + execution_id, deferred_name, exit_json, updated_at_ms + ) VALUES ( + ${clock.executionId}, ${clock.deferredName}, ${encoded}::jsonb, ${now} + ) + ON CONFLICT (execution_id, deferred_name) DO NOTHING + `; + yield* sql` + UPDATE platform_workflow_execution + SET resume_requested = TRUE, + status = CASE WHEN status = 'suspended' THEN 'pending' ELSE status END, + result_json = CASE + WHEN status = 'suspended' THEN NULL + ELSE result_json + END, + lease_token = CASE + WHEN status = 'suspended' THEN NULL + ELSE lease_token + END, + leased_until_ms = CASE + WHEN status = 'suspended' THEN NULL + ELSE leased_until_ms + END, + updated_at_ms = ${now} + WHERE execution_id = ${clock.executionId} + AND status IN ('running', 'suspended') + `; + yield* sql` + DELETE FROM platform_workflow_clock + WHERE execution_id = ${clock.executionId} + AND deferred_name = ${clock.deferredName} + `; + }), + { discard: true }, + ); + return due; + }), + ); + yield* Effect.forEach(rows, (row) => startExecution(row.executionId), { + discard: true, + }); + }); + + const recoverExecutions = Effect.gen(function* () { + const workflowNames = [...registered.keys()]; + if (workflowNames.length === 0) return; + const now = Date.now(); + const rows = yield* sql` + SELECT execution_id AS "executionId" + FROM platform_workflow_execution + WHERE ${sql.in("workflow_name", workflowNames)} + AND status IN ('pending', 'running') + AND (leased_until_ms IS NULL OR leased_until_ms <= ${now}) + ORDER BY updated_at_ms ASC + LIMIT 50 + `; + yield* Effect.forEach(rows, (row) => startExecution(row.executionId), { + discard: true, + }); + }); + + const tick = processDueClocks.pipe(Effect.andThen(recoverExecutions)); + + const executeWorkflow = ( + workflow: Workflow.Any, + options: { + readonly executionId: string; + readonly payload: object; + readonly discard: Discard; + readonly parent?: WorkflowEngine.WorkflowInstance["Service"] | undefined; + }, + ): Effect.Effect< + Discard extends true ? void : Workflow.Result + > => + Effect.gen(function* () { + yield* ensureExecution( + workflow, + options.executionId, + options.payload, + options.parent, + ); + yield* startExecution(options.executionId); + if (options.discard) return; + return yield* waitForResult(workflow, options.executionId); + }).pipe(Effect.orDie) as Effect.Effect< + Discard extends true ? void : Workflow.Result + >; + + engine = WorkflowEngine.makeUnsafe({ + register: (workflow, execute) => + Effect.gen(function* () { + registered.set(workflow._tag, { + workflow, + execute, + scope: yield* Effect.scope, + }); + yield* recoverExecutions; + }).pipe(Effect.orDie), + execute: executeWorkflow, + poll: (workflow, executionId) => pollResult(workflow, executionId).pipe(Effect.orDie), + interrupt: (_workflow, executionId) => + Effect.gen(function* () { + yield* sql` + UPDATE platform_workflow_execution + SET interrupted = TRUE, resume_requested = FALSE, + status = 'pending', result_json = NULL, + lease_token = NULL, leased_until_ms = NULL, updated_at_ms = ${Date.now()} + WHERE execution_id = ${executionId} AND status <> 'complete' + `; + const running = active.get(executionId); + if (running) { + running.instance.interrupted = true; + yield* running.interrupt; + } else { + yield* startExecution(executionId); + } + }).pipe(Effect.orDie), + interruptUnsafe: (_workflow, executionId) => + Effect.gen(function* () { + yield* sql` + UPDATE platform_workflow_execution + SET interrupted = TRUE, resume_requested = FALSE, + status = 'pending', result_json = NULL, + lease_token = NULL, leased_until_ms = NULL, updated_at_ms = ${Date.now()} + WHERE execution_id = ${executionId} AND status <> 'complete' + `; + const running = active.get(executionId); + if (running) { + running.instance.interrupted = true; + yield* running.interrupt; + } else { + yield* startExecution(executionId); + } + }).pipe(Effect.orDie), + resume: (_workflow, executionId) => + Effect.gen(function* () { + yield* sql` + UPDATE platform_workflow_execution + SET interrupted = FALSE, resume_requested = FALSE, + status = 'pending', result_json = NULL, + lease_token = NULL, leased_until_ms = NULL, updated_at_ms = ${Date.now()} + WHERE execution_id = ${executionId} AND status <> 'complete' + `; + yield* startExecution(executionId); + }).pipe(Effect.orDie), + activityExecute: (activity, attempt) => + Effect.gen(function* () { + const parent = yield* WorkflowEngine.WorkflowInstance; + const rows = yield* sql` + SELECT result_json AS "result" + FROM platform_workflow_activity + WHERE execution_id = ${parent.executionId} + AND activity_name = ${activity.name} + AND attempt = ${attempt} + `; + if (rows[0]) { + const stored = yield* decodeActivityResult(rows[0].result); + if (stored._tag === "Complete") return stored; + yield* sql` + DELETE FROM platform_workflow_activity + WHERE execution_id = ${parent.executionId} + AND activity_name = ${activity.name} + AND attempt = ${attempt} + `; + } + + const instance = WorkflowEngine.WorkflowInstance.initial( + parent.workflow, + parent.executionId, + ); + instance.interrupted = parent.interrupted; + const result = yield* activity.executeEncoded.pipe( + Workflow.intoResult, + Effect.provideService(WorkflowEngine.WorkflowInstance, instance), + ); + const encoded = yield* encodeActivityResult(result); + yield* sql` + INSERT INTO platform_workflow_activity ( + execution_id, activity_name, attempt, result_json, updated_at_ms + ) VALUES ( + ${parent.executionId}, ${activity.name}, ${attempt}, ${encoded}::jsonb, + ${Date.now()} + ) + ON CONFLICT (execution_id, activity_name, attempt) + DO NOTHING + `; + return result; + }).pipe(Effect.orDie), + deferredResult: (deferred) => + Effect.gen(function* () { + const instance = yield* WorkflowEngine.WorkflowInstance; + const rows = yield* sql` + SELECT exit_json AS "exit" + FROM platform_workflow_deferred + WHERE execution_id = ${instance.executionId} + AND deferred_name = ${deferred.name} + `; + return rows[0] + ? Option.some(rows[0].exit as Exit.Exit) + : Option.none(); + }).pipe(Effect.orDie), + deferredDone: (options) => + Effect.gen(function* () { + const encoded = yield* encodeJson(options.exit); + const now = Date.now(); + yield* sql` + INSERT INTO platform_workflow_deferred ( + execution_id, deferred_name, exit_json, updated_at_ms + ) VALUES ( + ${options.executionId}, ${options.deferredName}, ${encoded}::jsonb, ${now} + ) + ON CONFLICT (execution_id, deferred_name) DO NOTHING + `; + yield* sql` + UPDATE platform_workflow_execution + SET resume_requested = TRUE, + status = CASE WHEN status = 'suspended' THEN 'pending' ELSE status END, + result_json = CASE WHEN status = 'suspended' THEN NULL ELSE result_json END, + lease_token = CASE WHEN status = 'suspended' THEN NULL ELSE lease_token END, + leased_until_ms = CASE + WHEN status = 'suspended' THEN NULL + ELSE leased_until_ms + END, + updated_at_ms = ${now} + WHERE execution_id = ${options.executionId} + AND status IN ('running', 'suspended') + `; + yield* startExecution(options.executionId); + }).pipe(Effect.orDie), + scheduleClock: (workflow, options) => + Effect.gen(function* () { + const exitCodec = options.clock.deferred.exitSchema as unknown as Schema.Codec< + Exit.Exit, + Schema.Json, + never + >; + const encodedExit = yield* Schema.encodeEffect(exitCodec)(Exit.void).pipe( + Effect.flatMap(encodeJson), + ); + const now = Date.now(); + const dueAt = now + Duration.toMillis(options.clock.duration); + yield* sql` + INSERT INTO platform_workflow_clock ( + execution_id, workflow_name, deferred_name, due_at_ms, + exit_json, created_at_ms + ) VALUES ( + ${options.executionId}, ${workflow._tag}, ${options.clock.deferred.name}, + ${dueAt}, ${encodedExit}::jsonb, ${now} + ) + ON CONFLICT (execution_id, deferred_name) DO NOTHING + `; + }).pipe(Effect.orDie), + }); + + yield* Effect.forever( + tick.pipe( + Effect.catchCause((cause) => + Effect.logError("workflow runner tick failed", { cause: String(cause) }), + ), + Effect.andThen(Effect.sleep("100 millis")), + ), + ).pipe(Effect.forkIn(scope)); + + return engine; + }); + +/** Postgres-backed Effect workflow engine with durable activities and clocks. */ +export const PgWorkflowEngineLive = ( + config: PgPlatformConfig, +): Layer.Layer => + Layer.effect( + WorkflowEngine.WorkflowEngine, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* ensureTables(sql); + return yield* makePgEngine(sql); + }), + ).pipe(Layer.provide(PgPlatformClientLive(config)), Layer.orDie); diff --git a/selfhost/platform-node/src/PlatformRuntime.ts b/selfhost/platform-node/src/PlatformRuntime.ts new file mode 100644 index 00000000..3647bd6f --- /dev/null +++ b/selfhost/platform-node/src/PlatformRuntime.ts @@ -0,0 +1,6 @@ +import { Layer } from "effect"; + +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; + +/** Platform runtime marker for the single-process Node composition. */ +export const NodePlatformRuntimeLive = Layer.succeed(PlatformRuntime, PlatformRuntime.of({})); diff --git a/selfhost/platform-node/src/Postgres.ts b/selfhost/platform-node/src/Postgres.ts new file mode 100644 index 00000000..daacb7bc --- /dev/null +++ b/selfhost/platform-node/src/Postgres.ts @@ -0,0 +1,24 @@ +import * as PgClient from "@effect/sql-pg/PgClient"; +import type { Redacted } from "effect"; +import type { ConnectionOptions } from "node:tls"; + +/** Postgres connection parameters shared by single-node platform adapters. */ +export interface PgPlatformConfig { + readonly host: string; + readonly port: number; + readonly database: string; + readonly username: string; + readonly password: Redacted.Redacted; + readonly ssl?: boolean | ConnectionOptions; +} + +/** Builds a Postgres client layer for a single-node platform adapter. */ +export const PgPlatformClientLive = (config: PgPlatformConfig) => + PgClient.layer({ + host: config.host, + port: config.port, + database: config.database, + username: config.username, + password: config.password, + ssl: config.ssl, + }); diff --git a/selfhost/platform-node/src/Queue.ts b/selfhost/platform-node/src/Queue.ts new file mode 100644 index 00000000..3932e058 --- /dev/null +++ b/selfhost/platform-node/src/Queue.ts @@ -0,0 +1,342 @@ +import { + QueueConsumerError, + type QueueConsumerOptions, + QueueDriver, + type QueueDriverShape, + QueueProducerError, + type QueueProducer, +} from "@voidhash/platform/Queue"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { Cause, Duration, Effect, Layer, Schema, SchemaParser } from "effect"; +import { SqlClient } from "effect/unstable/sql"; + +import { PgPlatformClientLive, type PgPlatformConfig } from "./Postgres.ts"; + +interface QueueRow { + readonly id: string; + readonly body: unknown; + readonly attempt: number | string; + readonly sequence: number | string; +} + +interface DecodedQueueRow { + readonly row: QueueRow; + readonly message: A; +} + +const defaultOptions = { + batchSize: 10, + maxRetries: 3, + retryDelayMillis: 1_000, + visibilityTimeoutMillis: 30_000, + pollIntervalMillis: 250, +} as const; + +const positiveInteger = (value: number | undefined, fallback: number): number => + value === undefined || !Number.isFinite(value) || value <= 0 + ? fallback + : Math.floor(value); + +const nonNegativeInteger = (value: number | undefined, fallback: number): number => + value === undefined || !Number.isFinite(value) || value < 0 + ? fallback + : Math.floor(value); + +const resolvedOptions = (options: QueueConsumerOptions | undefined) => ({ + batchSize: positiveInteger(options?.batchSize, defaultOptions.batchSize), + maxRetries: nonNegativeInteger(options?.maxRetries, defaultOptions.maxRetries), + retryDelayMillis: nonNegativeInteger( + options?.retryDelayMillis, + defaultOptions.retryDelayMillis, + ), + visibilityTimeoutMillis: positiveInteger( + options?.visibilityTimeoutMillis, + defaultOptions.visibilityTimeoutMillis, + ), + pollIntervalMillis: positiveInteger( + options?.pollIntervalMillis, + defaultOptions.pollIntervalMillis, + ), + deadLetterQueue: options?.deadLetterQueue, +}); + +const encodeJson = (value: unknown): Effect.Effect => + Effect.try({ + try: () => { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new TypeError("Queue messages must be JSON-serializable"); + return encoded; + }, + catch: (cause) => cause, + }); + +const ensureTable = (sql: SqlClient.SqlClient) => + sql.withTransaction( + Effect.gen(function* () { + yield* sql`SELECT pg_advisory_xact_lock(hashtext('voidhash_platform_queue_schema_v1'))`; + yield* sql` + CREATE TABLE IF NOT EXISTS platform_queue_messages ( + id TEXT PRIMARY KEY, + queue_name TEXT NOT NULL, + body_json JSONB NOT NULL, + attempt INTEGER NOT NULL DEFAULT 0, + available_at_ms BIGINT NOT NULL, + leased_until_ms BIGINT, + created_at_ms BIGINT NOT NULL, + last_error TEXT, + sequence BIGSERIAL NOT NULL + ) + `; + yield* sql` + ALTER TABLE platform_queue_messages + ADD COLUMN IF NOT EXISTS sequence BIGSERIAL + `; + yield* sql` + CREATE INDEX IF NOT EXISTS platform_queue_messages_claim_v2_idx + ON platform_queue_messages (queue_name, available_at_ms, sequence) + `; + }), + ); + +const producerError = (queueName: string, cause: unknown) => + new QueueProducerError({ queueName, cause: String(cause) }); + +const consumerError = (queueName: string, cause: unknown) => + new QueueConsumerError({ queueName, cause: String(cause) }); + +const makeProducer = ( + sql: SqlClient.SqlClient, + queueName: string, + schema: Schema.Codec, +): QueueProducer => { + const encode = SchemaParser.encodeUnknownEffect(schema); + + const publishEncoded = (encoded: unknown) => + Effect.gen(function* () { + const body = yield* encodeJson(encoded); + const now = Date.now(); + yield* sql` + INSERT INTO platform_queue_messages ( + id, queue_name, body_json, available_at_ms, created_at_ms + ) VALUES ( + ${crypto.randomUUID()}, ${queueName}, ${body}::jsonb, ${now}, ${now} + ) + `; + }); + + const publish = (message: A) => + PlatformRuntime.pipe( + Effect.andThen(encode(message)), + Effect.flatMap(publishEncoded), + Effect.mapError((cause) => producerError(queueName, cause)), + ); + + const publishBatch = (messages: ReadonlyArray) => + PlatformRuntime.pipe( + Effect.andThen(Effect.forEach(messages, (message) => encode(message))), + Effect.flatMap((encoded) => + sql.withTransaction(Effect.forEach(encoded, publishEncoded, { discard: true })), + ), + Effect.mapError((cause) => producerError(queueName, cause)), + ); + + return { publish, publishBatch }; +}; + +const claimBatch = ( + sql: SqlClient.SqlClient, + queueName: string, + options: ReturnType, +) => + Effect.suspend(() => { + const now = Date.now(); + return sql` + WITH claimed AS ( + SELECT id + FROM platform_queue_messages + WHERE queue_name = ${queueName} + AND available_at_ms <= ${now} + AND (leased_until_ms IS NULL OR leased_until_ms <= ${now}) + ORDER BY sequence ASC + FOR UPDATE SKIP LOCKED + LIMIT ${options.batchSize} + ) + UPDATE platform_queue_messages AS message + SET attempt = message.attempt + 1, + leased_until_ms = ${now + options.visibilityTimeoutMillis} + FROM claimed + WHERE message.id = claimed.id + RETURNING message.id, message.body_json AS "body", message.attempt, message.sequence + `.pipe( + Effect.map((rows) => + [...rows].sort((left, right) => Number(left.sequence) - Number(right.sequence)), + ), + ); + }); + +const deleteRows = (sql: SqlClient.SqlClient, rows: ReadonlyArray) => + sql.withTransaction( + Effect.forEach( + rows, + (row) => + sql` + DELETE FROM platform_queue_messages + WHERE id = ${row.id} + `, + { discard: true }, + ), + ); + +const retryRows = ( + sql: SqlClient.SqlClient, + rows: ReadonlyArray, + queueName: string, + options: ReturnType, + cause: Cause.Cause, +) => { + const now = Date.now(); + const maxAttempts = options.maxRetries + 1; + const error = Cause.pretty(cause); + return sql.withTransaction( + Effect.forEach( + rows, + (row) => { + if (Number(row.attempt) < maxAttempts) { + return sql` + UPDATE platform_queue_messages + SET available_at_ms = ${now + options.retryDelayMillis}, + leased_until_ms = NULL, + last_error = ${error} + WHERE id = ${row.id} + `; + } + if (options.deadLetterQueue) { + return Effect.gen(function* () { + const body = yield* encodeJson(row.body); + yield* sql` + INSERT INTO platform_queue_messages ( + id, queue_name, body_json, available_at_ms, created_at_ms, last_error + ) VALUES ( + ${crypto.randomUUID()}, ${options.deadLetterQueue}, ${body}::jsonb, + ${now}, ${now}, ${error} + ) + `; + yield* sql` + DELETE FROM platform_queue_messages + WHERE id = ${row.id} + `; + }); + } + return sql` + DELETE FROM platform_queue_messages + WHERE id = ${row.id} + `; + }, + { discard: true }, + ), + ).pipe( + Effect.tap(() => + Effect.logWarning("queue batch failed; delivery state updated", { + queueName, + messageCount: rows.length, + cause: error, + }), + ), + ); +}; + +const processBatch = ( + sql: SqlClient.SqlClient, + queueName: string, + schema: Schema.Codec, + handleBatch: (messages: ReadonlyArray) => Effect.Effect, + inputOptions: QueueConsumerOptions | undefined, +): Effect.Effect => { + const options = resolvedOptions(inputOptions); + const decode = SchemaParser.decodeUnknownEffect(schema); + + return PlatformRuntime.pipe( + Effect.andThen(claimBatch(sql, queueName, options)), + Effect.flatMap((rows) => + Effect.gen(function* () { + if (rows.length === 0) return 0; + + const decoded: Array> = []; + const poison: Array = []; + yield* Effect.forEach( + rows, + (row) => + decode(row.body).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Effect.logWarning("queue payload decode failed; acking poison message", { + queueName, + messageId: row.id, + cause: Cause.pretty(cause), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + poison.push(row); + }), + ), + ), + onSuccess: (message) => + Effect.sync(() => { + decoded.push({ row, message }); + }), + }), + ), + { discard: true }, + ); + + if (poison.length > 0) yield* deleteRows(sql, poison); + if (decoded.length === 0) return rows.length; + + yield* handleBatch(decoded.map(({ message }) => message)).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + retryRows( + sql, + decoded.map(({ row }) => row), + queueName, + options, + cause, + ), + onSuccess: () => deleteRows(sql, decoded.map(({ row }) => row)), + }), + ); + return rows.length; + }), + ), + Effect.mapError((cause) => consumerError(queueName, cause)), + ); +}; + +const makeQueueDriver = (sql: SqlClient.SqlClient): QueueDriverShape => ({ + producer: (queueName, schema) => makeProducer(sql, queueName, schema), + processBatch: (queueName, schema, handleBatch, options) => + processBatch(sql, queueName, schema, handleBatch, options), + consumeBatch: (queueName, schema, handleBatch, options) => { + const resolved = resolvedOptions(options); + return Effect.forever( + processBatch(sql, queueName, schema, handleBatch, options).pipe( + Effect.flatMap((count) => + count === 0 + ? Effect.sleep(Duration.millis(resolved.pollIntervalMillis)) + : Effect.void, + ), + ), + ); + }, +}); + +/** Postgres-backed queue driver with durable leases, retries, and dead letters. */ +export const PgQueueLive = (config: PgPlatformConfig): Layer.Layer => + Layer.effect( + QueueDriver, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* ensureTable(sql); + return makeQueueDriver(sql); + }), + ).pipe(Layer.provide(PgPlatformClientLive(config)), Layer.orDie); diff --git a/selfhost/platform-node/src/Screenshot.ts b/selfhost/platform-node/src/Screenshot.ts new file mode 100644 index 00000000..7e5be28f --- /dev/null +++ b/selfhost/platform-node/src/Screenshot.ts @@ -0,0 +1,143 @@ +import { + Screenshot, + ScreenshotError, + type ScreenshotOptions, + type ScreenshotShape, +} from "@voidhash/platform/Screenshot"; +import { PlatformRuntime } from "@voidhash/platform/PlatformRuntime"; +import { Effect, Layer } from "effect"; +import { chromium, type Browser } from "playwright-core"; + +/** Headless Chromium launch and resource limits. */ +export interface ChromiumScreenshotConfig { + readonly executablePath?: string; + readonly disableSandbox?: boolean; + readonly timeoutMillis?: number; + readonly maxWidth?: number; + readonly maxHeight?: number; + readonly maxDeviceScaleFactor?: number; + readonly maxHtmlBytes?: number; + readonly maxRenderedPixels?: number; +} + +const screenshotError = (operation: string, cause: unknown) => + new ScreenshotError({ operation, cause: String(cause) }); + +const positiveInteger = (value: number, maximum: number): boolean => + Number.isInteger(value) && value > 0 && value <= maximum; + +/** Validates screenshot memory and viewport budgets before Chromium is invoked. */ +export const validateChromiumScreenshotOptions = ( + options: ScreenshotOptions, + config: ChromiumScreenshotConfig, +): Effect.Effect => { + const maxWidth = config.maxWidth ?? 4_096; + const maxHeight = config.maxHeight ?? 4_096; + const maxScale = config.maxDeviceScaleFactor ?? 4; + const maxHtmlBytes = config.maxHtmlBytes ?? 4 * 1_024 * 1_024; + const maxRenderedPixels = config.maxRenderedPixels ?? 16_777_216; + if (!positiveInteger(options.width, maxWidth)) { + return Effect.fail(screenshotError("validate", `width must be between 1 and ${maxWidth}`)); + } + if (!positiveInteger(options.height, maxHeight)) { + return Effect.fail(screenshotError("validate", `height must be between 1 and ${maxHeight}`)); + } + if ( + !Number.isFinite(options.deviceScaleFactor) || + options.deviceScaleFactor < 1 || + options.deviceScaleFactor > maxScale + ) { + return Effect.fail( + screenshotError("validate", `deviceScaleFactor must be between 1 and ${maxScale}`), + ); + } + if (new TextEncoder().encode(options.html).byteLength > maxHtmlBytes) { + return Effect.fail(screenshotError("validate", `html must be at most ${maxHtmlBytes} bytes`)); + } + const renderedPixels = + options.width * options.height * options.deviceScaleFactor * options.deviceScaleFactor; + if (renderedPixels > maxRenderedPixels) { + return Effect.fail( + screenshotError("validate", `rendered image must be at most ${maxRenderedPixels} pixels`), + ); + } + return Effect.void; +}; + +const render = (browser: Browser, config: ChromiumScreenshotConfig, options: ScreenshotOptions) => + validateChromiumScreenshotOptions(options, config).pipe( + Effect.andThen( + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => + browser.newContext({ + viewport: { width: Math.floor(options.width), height: Math.floor(options.height) }, + deviceScaleFactor: options.deviceScaleFactor, + javaScriptEnabled: false, + serviceWorkers: "block", + }), + catch: (cause) => screenshotError("openContext", cause), + }), + (context) => + Effect.tryPromise({ + try: async () => { + await context.setOffline(true); + const page = await context.newPage(); + await page.route("**/*", (route) => route.abort("blockedbyclient")); + await page.setContent(options.html, { + waitUntil: "load", + timeout: config.timeoutMillis ?? 15_000, + }); + return new Uint8Array( + await page.screenshot({ + type: "png", + fullPage: false, + animations: "disabled", + timeout: config.timeoutMillis ?? 15_000, + }), + ); + }, + catch: (cause) => screenshotError("render", cause), + }), + (context) => + Effect.promise(() => context.close()).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to close screenshot browser context", { + cause: String(cause), + }), + ), + ), + ), + ), + ); + +const makeRenderer = (browser: Browser, config: ChromiumScreenshotConfig): ScreenshotShape => ({ + renderPng: (options) => PlatformRuntime.pipe(Effect.andThen(render(browser, config, options))), +}); + +/** Chromium-backed PNG screenshot layer with network and JavaScript disabled. */ +export const ChromiumScreenshotLive = ( + config: ChromiumScreenshotConfig = {}, +): Layer.Layer => + Layer.effect( + Screenshot, + Effect.acquireRelease( + Effect.tryPromise({ + try: () => + chromium.launch({ + executablePath: config.executablePath ?? process.env.CHROMIUM_EXECUTABLE_PATH, + headless: true, + args: ["--disable-dev-shm-usage", ...(config.disableSandbox ? ["--no-sandbox"] : [])], + }), + catch: (cause) => screenshotError("launch", cause), + }), + (browser) => + Effect.promise(() => browser.close()).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to close screenshot browser", { + cause: String(cause), + }), + ), + ), + ).pipe(Effect.map((browser) => makeRenderer(browser, config))), + ); diff --git a/selfhost/platform-node/src/Workflow.ts b/selfhost/platform-node/src/Workflow.ts new file mode 100644 index 00000000..8d196751 --- /dev/null +++ b/selfhost/platform-node/src/Workflow.ts @@ -0,0 +1,10 @@ +import { EffectWorkflowRunnerLive } from "@voidhash/platform/EffectWorkflowRunner"; +import type { WorkflowRunner } from "@voidhash/platform/Workflow"; +import { Layer } from "effect"; + +import { PgWorkflowEngineLive } from "./PgWorkflowEngine.ts"; +import type { PgPlatformConfig } from "./Postgres.ts"; + +/** Postgres-backed provider-neutral workflow runner. */ +export const PgWorkflowRunnerLive = (config: PgPlatformConfig): Layer.Layer => + EffectWorkflowRunnerLive.pipe(Layer.provide(PgWorkflowEngineLive(config))); diff --git a/selfhost/platform-node/src/index.ts b/selfhost/platform-node/src/index.ts new file mode 100644 index 00000000..40e17732 --- /dev/null +++ b/selfhost/platform-node/src/index.ts @@ -0,0 +1,28 @@ +export { PgCronSchedulerLive } from "./CronScheduler.ts"; +export { SmtpMailerLive, type SmtpMailerConfig } from "./Mailer.ts"; +export { S3ObjectStoreLive, type S3ObjectStoreConfig } from "./ObjectStore.ts"; +export { + ChromiumScreenshotLive, + type ChromiumScreenshotConfig, +} from "./Screenshot.ts"; +export { PgWorkflowEngineLive } from "./PgWorkflowEngine.ts"; +export { PgWorkflowRunnerLive } from "./Workflow.ts"; +export { + type DueDurableEntityAlarm, + NodeDurableEntityControl, + type NodeDurableEntityControlShape, + type PgDurableEntityConfig, + PgDurableEntityHostLive, +} from "./DurableEntity.ts"; +export { PgKeyValueStoreLive } from "./KeyValueStore.ts"; +export { + makeMemoryDurableEntityHost, + MemoryDurableEntityHostLive, +} from "./MemoryDurableEntity.ts"; +export { + makeNodeDurableEntitySession, + type NodeWebSocketLike, +} from "./NodeDurableEntitySession.ts"; +export { NodePlatformRuntimeLive } from "./PlatformRuntime.ts"; +export { PgPlatformClientLive, type PgPlatformConfig } from "./Postgres.ts"; +export { PgQueueLive } from "./Queue.ts"; diff --git a/selfhost/platform-node/tests/ChromiumScreenshot.integration.test.ts b/selfhost/platform-node/tests/ChromiumScreenshot.integration.test.ts new file mode 100644 index 00000000..3a14b15e --- /dev/null +++ b/selfhost/platform-node/tests/ChromiumScreenshot.integration.test.ts @@ -0,0 +1,96 @@ +import { Screenshot, ScreenshotError } from "@voidhash/platform/Screenshot"; +import { Effect, Layer } from "effect"; +import { createServer } from "node:http"; +import { describe, expect, it } from "vitest"; + +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import { ChromiumScreenshotLive } from "../src/Screenshot.ts"; + +const executablePath = + process.env.PLATFORM_NODE_CHROMIUM_EXECUTABLE_PATH ?? + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; +const screenshotLayer = () => + Layer.merge(ChromiumScreenshotLive({ executablePath }), NodePlatformRuntimeLive); +const describeChromium = process.env.PLATFORM_NODE_CHROMIUM_TEST === "1" ? describe : describe.skip; + +const pngDimensions = (png: Uint8Array) => { + const view = new DataView(png.buffer, png.byteOffset, png.byteLength); + return { width: view.getUint32(16), height: view.getUint32(20) }; +}; + +describeChromium("Chromium screenshot renderer", () => { + it("renders a viewport PNG at the requested device scale", async () => { + const png = await Effect.runPromise( + Effect.gen(function* () { + const screenshot = yield* Screenshot; + return yield* screenshot.renderPng({ + html: "", + width: 160, + height: 90, + deviceScaleFactor: 2, + }); + }).pipe(Effect.provide(screenshotLayer())), + ); + + expect(Array.from(png.subarray(0, 8))).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + expect(pngDimensions(png)).toEqual({ width: 320, height: 180 }); + }); + + it("blocks resource loads and redirecting navigation before any network access", async () => { + let requestCount = 0; + const server = createServer((_request, response) => { + requestCount += 1; + response.writeHead(302, { location: "http://169.254.169.254/latest/meta-data/" }); + response.end(); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("test server did not expose a TCP port"); + } + + try { + const url = `http://127.0.0.1:${address.port}/blocked.png`; + const png = await Effect.runPromise( + Effect.gen(function* () { + const screenshot = yield* Screenshot; + return yield* screenshot.renderPng({ + html: ` + + + + + `, + width: 40, + height: 30, + deviceScaleFactor: 1, + }); + }).pipe(Effect.provide(screenshotLayer())), + ); + + expect(pngDimensions(png)).toEqual({ width: 40, height: 30 }); + expect(requestCount).toBe(0); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + + it("validates viewport limits through the stable error channel", async () => { + const error = await Effect.runPromise( + Effect.gen(function* () { + const screenshot = yield* Screenshot; + return yield* screenshot + .renderPng({ html: "", width: 0, height: 30, deviceScaleFactor: 1 }) + .pipe(Effect.flip); + }).pipe(Effect.provide(screenshotLayer())), + ); + + expect(error).toBeInstanceOf(ScreenshotError); + expect(error.operation).toBe("validate"); + }); +}); diff --git a/selfhost/platform-node/tests/MemoryDurableEntity.test.ts b/selfhost/platform-node/tests/MemoryDurableEntity.test.ts new file mode 100644 index 00000000..2626d585 --- /dev/null +++ b/selfhost/platform-node/tests/MemoryDurableEntity.test.ts @@ -0,0 +1,73 @@ +import { makeDurableEntityAddress } from "@voidhash/platform/DurableEntity"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { makeMemoryDurableEntityHost } from "../src/MemoryDurableEntity.ts"; + +describe("memory durable entity host", () => { + it("serializes one address while allowing different addresses to overlap", async () => { + const host = makeMemoryDurableEntityHost(); + const first = makeDurableEntityAddress("document", "first"); + const second = makeDurableEntityAddress("document", "second"); + const events: string[] = []; + + await Effect.runPromise( + Effect.all( + [ + host.run(first, () => + Effect.gen(function* () { + events.push("first:start"); + yield* Effect.sleep("30 millis"); + events.push("first:end"); + }), + ), + host.run(first, () => Effect.sync(() => void events.push("first:next"))), + host.run(second, () => + Effect.gen(function* () { + events.push("second:start"); + yield* Effect.sleep("5 millis"); + events.push("second:end"); + }), + ), + ], + { concurrency: "unbounded" }, + ), + ); + + expect(events.indexOf("second:end")).toBeLessThan(events.indexOf("first:end")); + expect(events.indexOf("first:end")).toBeLessThan(events.indexOf("first:next")); + }); + + it("retains entity-local KV, alarm, and session state", async () => { + const host = makeMemoryDurableEntityHost(); + const address = makeDurableEntityAddress("document", "stateful"); + const session = { + id: "session-1", + send: () => Effect.void, + close: () => Effect.void, + getAttachment: Effect.succeed(undefined), + setAttachment: () => Effect.void, + }; + + await Effect.runPromise( + host.run(address, (entity) => + Effect.gen(function* () { + yield* entity.keyValue.put("value", { count: 1 }); + yield* entity.alarm.set(1234); + yield* entity.sessions.attach(session); + }), + ), + ); + + const state = await Effect.runPromise( + host.run(address, (entity) => + Effect.all({ + value: entity.keyValue.get("value"), + alarm: entity.alarm.get, + sessions: entity.sessions.list, + }), + ), + ); + expect(state).toEqual({ value: { count: 1 }, alarm: 1234, sessions: [session] }); + }); +}); diff --git a/selfhost/platform-node/tests/MemoryDurableEntityConformance.test.ts b/selfhost/platform-node/tests/MemoryDurableEntityConformance.test.ts new file mode 100644 index 00000000..ee190d37 --- /dev/null +++ b/selfhost/platform-node/tests/MemoryDurableEntityConformance.test.ts @@ -0,0 +1,10 @@ +import { durableEntityHostConformance } from "@voidhash/platform/conformance"; + +import { MemoryDurableEntityHostLive } from "../src/MemoryDurableEntity.ts"; + +// The shared suite is what keeps adapters interchangeable: the in-memory host +// and the cluster host answer to exactly the same assertions. +durableEntityHostConformance({ + name: "memory", + layer: () => MemoryDurableEntityHostLive, +}); diff --git a/selfhost/platform-node/tests/NodeDurableEntitySession.test.ts b/selfhost/platform-node/tests/NodeDurableEntitySession.test.ts new file mode 100644 index 00000000..7d003317 --- /dev/null +++ b/selfhost/platform-node/tests/NodeDurableEntitySession.test.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { makeNodeDurableEntitySession } from "../src/NodeDurableEntitySession.ts"; + +describe("Node durable entity session", () => { + it("forwards socket operations and keeps attachments", async () => { + const sent: Array = []; + const closed: Array = []; + const session = makeNodeDurableEntitySession( + "session-1", + { + send: (message) => void sent.push(message), + close: (code, reason) => void closed.push([code, reason]), + }, + { authenticated: false }, + ); + + await Effect.runPromise(session.send("hello")); + await Effect.runPromise(session.setAttachment({ authenticated: true })); + await Effect.runPromise(session.close(1000, "done")); + + expect(sent).toEqual(["hello"]); + expect(await Effect.runPromise(session.getAttachment)).toEqual({ authenticated: true }); + expect(closed).toEqual([[1000, "done"]]); + }); +}); diff --git a/selfhost/platform-node/tests/PgCronScheduler.integration.test.ts b/selfhost/platform-node/tests/PgCronScheduler.integration.test.ts new file mode 100644 index 00000000..6554557c --- /dev/null +++ b/selfhost/platform-node/tests/PgCronScheduler.integration.test.ts @@ -0,0 +1,215 @@ +import { + type CronJobContext, + CronScheduler, + CronSchedulerError, +} from "@voidhash/platform/CronScheduler"; +import { Effect, Layer, Redacted } from "effect"; +import { describe, expect, it } from "vitest"; + +import { PgCronSchedulerLive } from "../src/CronScheduler.ts"; +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import type { PgPlatformConfig } from "../src/Postgres.ts"; + +const config: PgPlatformConfig = { + host: process.env.PLATFORM_NODE_PG_HOST ?? "127.0.0.1", + port: Number(process.env.PLATFORM_NODE_PG_PORT ?? "5432"), + database: process.env.PLATFORM_NODE_PG_DATABASE ?? "voidhash", + username: process.env.PLATFORM_NODE_PG_USERNAME ?? "voidhash", + password: Redacted.make(process.env.PLATFORM_NODE_PG_PASSWORD ?? "password"), +}; + +const schedulerLayer = () => Layer.merge(PgCronSchedulerLive(config), NodePlatformRuntimeLive); +const describePg = process.env.PLATFORM_NODE_PG_TEST === "1" ? describe : describe.skip; + +describePg("Postgres cron scheduler", () => { + it("persists schedule progress and catches up missed slots after restart", async () => { + const name = `cron-catch-up-${crypto.randomUUID()}`; + const runs: Array = []; + const job = { + name, + expression: "* * * * *", + timeZone: "UTC", + run: (context: CronJobContext) => + Effect.sync(() => { + runs.push(context); + }), + }; + const initialTime = new Date("2026-07-10T12:00:30.000Z"); + + const initialized = await Effect.runPromise( + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + return yield* scheduler.tick(job, initialTime); + }).pipe(Effect.provide(schedulerLayer())), + ); + expect(initialized).toBe(false); + + const first = await Effect.runPromise( + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + return yield* scheduler.tick(job, new Date("2026-07-10T12:01:00.000Z")); + }).pipe(Effect.provide(schedulerLayer())), + ); + expect(first).toBe(true); + + const catchUp = await Effect.runPromise( + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + return yield* Effect.all([ + scheduler.tick(job, new Date("2026-07-10T12:03:30.000Z")), + scheduler.tick(job, new Date("2026-07-10T12:03:30.000Z")), + scheduler.tick(job, new Date("2026-07-10T12:03:30.000Z")), + ]); + }).pipe(Effect.provide(schedulerLayer())), + ); + + expect(catchUp).toEqual([true, true, false]); + expect(runs).toEqual([ + { scheduledTime: new Date("2026-07-10T12:01:00.000Z"), catchUp: false }, + { scheduledTime: new Date("2026-07-10T12:02:00.000Z"), catchUp: true }, + { scheduledTime: new Date("2026-07-10T12:03:00.000Z"), catchUp: true }, + ]); + }); + + it("leases one schedule slot across concurrent ticks", async () => { + const name = `cron-concurrent-${crypto.randomUUID()}`; + let runCount = 0; + const job = { + name, + expression: "* * * * *", + timeZone: "UTC", + run: () => + Effect.sync(() => { + runCount += 1; + }).pipe(Effect.andThen(Effect.sleep("30 millis"))), + }; + + const result = await Effect.runPromise( + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + yield* scheduler.tick(job, new Date("2026-07-10T12:00:30.000Z")); + return yield* Effect.all( + [ + scheduler.tick(job, new Date("2026-07-10T12:01:00.000Z")), + scheduler.tick(job, new Date("2026-07-10T12:01:00.000Z")), + ], + { concurrency: "unbounded" }, + ); + }).pipe(Effect.provide(schedulerLayer())), + ); + + expect([...result].sort()).toEqual([false, true]); + expect(runCount).toBe(1); + }); + + it("releases a failed slot so the same scheduled run can retry", async () => { + const name = `cron-retry-${crypto.randomUUID()}`; + const initialTime = new Date("2026-07-10T12:00:30.000Z"); + const dueTime = new Date("2026-07-10T12:01:00.000Z"); + + const failure = await Effect.runPromise( + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + const failingJob = { + name, + expression: "* * * * *", + timeZone: "UTC", + run: () => Effect.fail("job failed"), + }; + yield* scheduler.tick(failingJob, initialTime); + return yield* scheduler.tick(failingJob, dueTime).pipe(Effect.flip); + }).pipe(Effect.provide(schedulerLayer())), + ); + + expect(failure).toBeInstanceOf(CronSchedulerError); + expect(failure.operation).toBe("run"); + + const retried: Array = []; + const success = await Effect.runPromise( + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + return yield* scheduler.tick( + { + name, + expression: "* * * * *", + timeZone: "UTC", + run: ({ scheduledTime }) => + Effect.sync(() => { + retried.push(scheduledTime); + }), + }, + dueTime, + ); + }).pipe(Effect.provide(schedulerLayer())), + ); + + expect(success).toBe(true); + expect(retried).toEqual([dueTime]); + }); + + it("rejects an invalid expression through the stable error channel", async () => { + const error = await Effect.runPromise( + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + return yield* scheduler + .tick({ + name: `cron-invalid-${crypto.randomUUID()}`, + expression: "not a cron", + run: () => Effect.void, + }) + .pipe(Effect.flip); + }).pipe(Effect.provide(schedulerLayer())), + ); + + expect(error).toBeInstanceOf(CronSchedulerError); + expect(error.operation).toBe("parse"); + }); + + it("resets future scheduling when a job definition changes", async () => { + const name = `cron-definition-${crypto.randomUUID()}`; + const runs: Array = []; + + const result = await Effect.runPromise( + Effect.gen(function* () { + const scheduler = yield* CronScheduler; + yield* scheduler.tick( + { + name, + expression: "* * * * *", + timeZone: "UTC", + run: () => Effect.void, + }, + new Date("2026-07-10T12:00:30.000Z"), + ); + const oldSlot = yield* scheduler.tick( + { + name, + expression: "0 * * * *", + timeZone: "UTC", + run: ({ scheduledTime }) => + Effect.sync(() => { + runs.push(scheduledTime); + }), + }, + new Date("2026-07-10T12:01:00.000Z"), + ); + const newSlot = yield* scheduler.tick( + { + name, + expression: "0 * * * *", + timeZone: "UTC", + run: ({ scheduledTime }) => + Effect.sync(() => { + runs.push(scheduledTime); + }), + }, + new Date("2026-07-10T13:00:00.000Z"), + ); + return { oldSlot, newSlot }; + }).pipe(Effect.provide(schedulerLayer())), + ); + + expect(result).toEqual({ oldSlot: false, newSlot: true }); + expect(runs).toEqual([new Date("2026-07-10T13:00:00.000Z")]); + }); +}); diff --git a/selfhost/platform-node/tests/PgDurableEntity.integration.test.ts b/selfhost/platform-node/tests/PgDurableEntity.integration.test.ts new file mode 100644 index 00000000..2454959a --- /dev/null +++ b/selfhost/platform-node/tests/PgDurableEntity.integration.test.ts @@ -0,0 +1,127 @@ +import { + DurableEntityHost, + makeDurableEntityAddress, +} from "@voidhash/platform/DurableEntity"; +import { Effect, Redacted } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + NodeDurableEntityControl, + PgDurableEntityHostLive, + type PgDurableEntityConfig, +} from "../src/DurableEntity.ts"; + +const config: PgDurableEntityConfig = { + host: process.env.PLATFORM_NODE_PG_HOST ?? "127.0.0.1", + port: Number(process.env.PLATFORM_NODE_PG_PORT ?? "5432"), + database: process.env.PLATFORM_NODE_PG_DATABASE ?? "voidhash", + username: process.env.PLATFORM_NODE_PG_USERNAME ?? "voidhash", + password: Redacted.make(process.env.PLATFORM_NODE_PG_PASSWORD ?? "password"), +}; + +const describePg = process.env.PLATFORM_NODE_PG_TEST === "1" ? describe : describe.skip; + +describePg("Postgres durable entity host", () => { + it("boots concurrent host layers without racing the schema migration", async () => { + const addresses = [ + makeDurableEntityAddress("integration", crypto.randomUUID()), + makeDurableEntityAddress("integration", crypto.randomUUID()), + ] as const; + + await Effect.runPromise( + Effect.all( + addresses.map((address) => + Effect.gen(function* () { + const host = yield* DurableEntityHost; + yield* host.run(address, (entity) => entity.keyValue.put("booted", true)); + }).pipe(Effect.provide(PgDurableEntityHostLive(config))), + ), + { concurrency: "unbounded" }, + ), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + yield* Effect.forEach( + addresses, + (address) => host.run(address, (entity) => entity.keyValue.delete("booted")), + { discard: true }, + ); + }).pipe(Effect.provide(PgDurableEntityHostLive(config))), + ); + }); + + it("persists KV, entity-local SQL, and alarms across layer restarts", async () => { + const address = makeDurableEntityAddress("integration", crypto.randomUUID()); + const otherAddress = makeDurableEntityAddress("integration", crypto.randomUUID()); + const scheduledTime = Date.now() - 1; + + await Effect.runPromise( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + yield* host.run(address, (entity) => + Effect.gen(function* () { + yield* entity.keyValue.put("profile", { name: "node" }); + yield* entity.keyValue.put("label", "node-string"); + yield* entity.alarm.set(scheduledTime); + yield* entity.sql!.execute("CREATE TABLE counter (value INTEGER NOT NULL)"); + yield* entity.sql!.execute("INSERT INTO counter (value) VALUES ($1)", [7]); + }), + ); + yield* host.run(otherAddress, (entity) => + Effect.gen(function* () { + yield* entity.sql!.execute("CREATE TABLE counter (value INTEGER NOT NULL)"); + yield* entity.sql!.execute("INSERT INTO counter (value) VALUES ($1)", [9]); + }), + ); + }).pipe(Effect.provide(PgDurableEntityHostLive(config))), + ); + + const restored = await Effect.runPromise( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + const control = yield* NodeDurableEntityControl; + const state = yield* host.run(address, (entity) => + Effect.all({ + profile: entity.keyValue.get("profile"), + label: entity.keyValue.get("label"), + alarm: entity.alarm.get, + rows: entity.sql!.execute<{ readonly value: number }>( + "SELECT value FROM counter", + ), + }), + ); + const otherRows = yield* host.run(otherAddress, (entity) => + entity.sql!.execute<{ readonly value: number }>("SELECT value FROM counter"), + ); + const due = yield* control.listDueAlarms(Date.now(), 100); + return { state, otherRows, due }; + }).pipe(Effect.provide(PgDurableEntityHostLive(config))), + ); + + expect(restored.state).toEqual({ + profile: { name: "node" }, + label: "node-string", + alarm: scheduledTime, + rows: [{ value: 7 }], + }); + expect(restored.otherRows).toEqual([{ value: 9 }]); + expect(restored.due).toContainEqual({ address, scheduledTime }); + + await Effect.runPromise( + Effect.gen(function* () { + const host = yield* DurableEntityHost; + yield* host.run(address, (entity) => + Effect.gen(function* () { + yield* entity.keyValue.delete("profile"); + yield* entity.keyValue.delete("label"); + yield* entity.alarm.delete; + yield* entity.sql!.execute("DROP TABLE counter"); + }), + ); + yield* host.run(otherAddress, (entity) => entity.sql!.execute("DROP TABLE counter")); + }).pipe(Effect.provide(PgDurableEntityHostLive(config))), + ); + }); +}); diff --git a/selfhost/platform-node/tests/PgKeyValueStore.integration.test.ts b/selfhost/platform-node/tests/PgKeyValueStore.integration.test.ts new file mode 100644 index 00000000..df189d15 --- /dev/null +++ b/selfhost/platform-node/tests/PgKeyValueStore.integration.test.ts @@ -0,0 +1,140 @@ +import { KeyValueStore, KeyValueStoreError } from "@voidhash/platform/KeyValueStore"; +import { Effect, Layer, Option, Redacted, Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { PgKeyValueStoreLive } from "../src/KeyValueStore.ts"; +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import type { PgPlatformConfig } from "../src/Postgres.ts"; + +const config: PgPlatformConfig = { + host: process.env.PLATFORM_NODE_PG_HOST ?? "127.0.0.1", + port: Number(process.env.PLATFORM_NODE_PG_PORT ?? "5432"), + database: process.env.PLATFORM_NODE_PG_DATABASE ?? "voidhash", + username: process.env.PLATFORM_NODE_PG_USERNAME ?? "voidhash", + password: Redacted.make(process.env.PLATFORM_NODE_PG_PASSWORD ?? "password"), +}; + +const storeLayer = () => Layer.merge(PgKeyValueStoreLive(config), NodePlatformRuntimeLive); +const describePg = process.env.PLATFORM_NODE_PG_TEST === "1" ? describe : describe.skip; + +describePg("Postgres key-value store", () => { + it("persists typed object and string values across layer restarts", async () => { + const namespace = `kv-persistence-${crypto.randomUUID()}`; + const profileSchema = Schema.Struct({ name: Schema.String, version: Schema.Number }); + + await Effect.runPromise( + Effect.gen(function* () { + const store = yield* KeyValueStore; + yield* store.put( + namespace, + "profile", + { name: "node", version: 1 }, + profileSchema, + ); + yield* store.put(namespace, "label", "node-string", Schema.String); + }).pipe(Effect.provide(storeLayer())), + ); + + const restored = await Effect.runPromise( + Effect.gen(function* () { + const store = yield* KeyValueStore; + const values = yield* Effect.all({ + profile: store.get(namespace, "profile", profileSchema), + label: store.get(namespace, "label", Schema.String), + }); + yield* store.deleteMany(namespace, ["profile", "label"]); + return values; + }).pipe(Effect.provide(storeLayer())), + ); + + expect(Option.getOrThrow(restored.profile)).toEqual({ name: "node", version: 1 }); + expect(Option.getOrThrow(restored.label)).toBe("node-string"); + }); + + it("supports bulk existence checks and expiry pruning", async () => { + const namespace = `kv-expiry-${crypto.randomUUID()}`; + + const result = await Effect.runPromise( + Effect.gen(function* () { + const store = yield* KeyValueStore; + yield* store.putMany( + namespace, + [ + { key: "one", value: 1 }, + { key: "two", value: 2 }, + { key: "three", value: 3 }, + ], + Schema.Number, + { ttlMillis: 20 }, + ); + const before = yield* store.existingKeys(namespace, ["one", "two", "three", "missing"]); + yield* Effect.sleep("40 millis"); + const after = yield* store.existingKeys(namespace, ["one", "two", "three"]); + const pruned = yield* store.pruneExpired(10_000); + return { before, after, pruned }; + }).pipe(Effect.provide(storeLayer())), + ); + + expect(result.before).toEqual(new Set(["one", "two", "three"])); + expect(result.after.size).toBe(0); + expect(result.pruned).toBeGreaterThanOrEqual(3); + }); + + it("increments counters atomically under concurrency", async () => { + const namespace = `kv-counter-${crypto.randomUUID()}`; + const key = "requests"; + + const result = await Effect.runPromise( + Effect.gen(function* () { + const store = yield* KeyValueStore; + const increments = yield* Effect.all( + Array.from({ length: 50 }, () => store.increment(namespace, key)), + { concurrency: "unbounded" }, + ); + const stored = yield* store.get(namespace, key, Schema.Number); + yield* store.delete(namespace, key); + return { increments, stored }; + }).pipe(Effect.provide(storeLayer())), + ); + + expect([...result.increments].sort((left, right) => left - right)).toEqual( + Array.from({ length: 50 }, (_, index) => index + 1), + ); + expect(Option.getOrThrow(result.stored)).toBe(50); + }); + + it("restarts an expired counter at one", async () => { + const namespace = `kv-counter-expiry-${crypto.randomUUID()}`; + const key = "events"; + + const values = await Effect.runPromise( + Effect.gen(function* () { + const store = yield* KeyValueStore; + const first = yield* store.increment(namespace, key, { ttlMillis: 20 }); + yield* Effect.sleep("40 millis"); + const reset = yield* store.increment(namespace, key, { ttlMillis: 20 }); + yield* store.delete(namespace, key); + return { first, reset }; + }).pipe(Effect.provide(storeLayer())), + ); + + expect(values).toEqual({ first: 1, reset: 1 }); + }); + + it("maps schema mismatches to the stable store error", async () => { + const namespace = `kv-schema-${crypto.randomUUID()}`; + + const error = await Effect.runPromise( + Effect.gen(function* () { + const store = yield* KeyValueStore; + yield* store.put(namespace, "value", "text", Schema.String); + const failure = yield* store.get(namespace, "value", Schema.Number).pipe(Effect.flip); + yield* store.delete(namespace, "value"); + return failure; + }).pipe(Effect.provide(storeLayer())), + ); + + expect(error).toBeInstanceOf(KeyValueStoreError); + expect(error.operation).toBe("get"); + }); +}); diff --git a/selfhost/platform-node/tests/PgQueue.integration.test.ts b/selfhost/platform-node/tests/PgQueue.integration.test.ts new file mode 100644 index 00000000..b2916691 --- /dev/null +++ b/selfhost/platform-node/tests/PgQueue.integration.test.ts @@ -0,0 +1,200 @@ +import { QueueDriver } from "@voidhash/platform/Queue"; +import { Deferred, Effect, Fiber, Layer, Option, Redacted, Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import type { PgPlatformConfig } from "../src/Postgres.ts"; +import { PgQueueLive } from "../src/Queue.ts"; + +const config: PgPlatformConfig = { + host: process.env.PLATFORM_NODE_PG_HOST ?? "127.0.0.1", + port: Number(process.env.PLATFORM_NODE_PG_PORT ?? "5432"), + database: process.env.PLATFORM_NODE_PG_DATABASE ?? "voidhash", + username: process.env.PLATFORM_NODE_PG_USERNAME ?? "voidhash", + password: Redacted.make(process.env.PLATFORM_NODE_PG_PASSWORD ?? "password"), +}; + +const messageSchema = Schema.Struct({ sequence: Schema.Number }); +const queueLayer = () => Layer.merge(PgQueueLive(config), NodePlatformRuntimeLive); +const describePg = process.env.PLATFORM_NODE_PG_TEST === "1" ? describe : describe.skip; + +describePg("Postgres queue driver", () => { + it("persists FIFO messages across layer restarts", async () => { + const queueName = `queue-persistence-${crypto.randomUUID()}`; + + await Effect.runPromise( + Effect.gen(function* () { + const queues = yield* QueueDriver; + yield* queues.producer(queueName, messageSchema).publishBatch([ + { sequence: 1 }, + { sequence: 2 }, + { sequence: 3 }, + ]); + }).pipe(Effect.provide(queueLayer())), + ); + + const messages: Array = []; + const processed = await Effect.runPromise( + Effect.gen(function* () { + const queues = yield* QueueDriver; + return yield* queues.processBatch( + queueName, + messageSchema, + (batch) => + Effect.sync(() => { + messages.push(...batch.map(({ sequence }) => sequence)); + }), + { batchSize: 10 }, + ); + }).pipe(Effect.provide(queueLayer())), + ); + + expect(processed).toBe(3); + expect(messages).toEqual([1, 2, 3]); + }); + + it("retries failed deliveries before moving them to a dead-letter queue", async () => { + const queueName = `queue-retry-${crypto.randomUUID()}`; + const deadLetterQueue = `${queueName}-dlq`; + const options = { maxRetries: 1, retryDelayMillis: 0, deadLetterQueue } as const; + + await Effect.runPromise( + Effect.gen(function* () { + const queues = yield* QueueDriver; + yield* queues.producer(queueName, messageSchema).publish({ sequence: 7 }); + yield* queues.processBatch( + queueName, + messageSchema, + () => Effect.fail("first failure"), + options, + ); + yield* queues.processBatch( + queueName, + messageSchema, + () => Effect.fail("second failure"), + options, + ); + }).pipe(Effect.provide(queueLayer())), + ); + + const deadLetters: Array = []; + const result = await Effect.runPromise( + Effect.gen(function* () { + const queues = yield* QueueDriver; + const originalCount = yield* queues.processBatch( + queueName, + messageSchema, + () => Effect.void, + options, + ); + const deadLetterCount = yield* queues.processBatch( + deadLetterQueue, + messageSchema, + (batch) => + Effect.sync(() => { + deadLetters.push(...batch.map(({ sequence }) => sequence)); + }), + ); + return { originalCount, deadLetterCount }; + }).pipe(Effect.provide(queueLayer())), + ); + + expect(result).toEqual({ originalCount: 0, deadLetterCount: 1 }); + expect(deadLetters).toEqual([7]); + }); + + it("acks poison messages without invoking the handler", async () => { + const queueName = `queue-poison-${crypto.randomUUID()}`; + let handled = false; + + const counts = await Effect.runPromise( + Effect.gen(function* () { + const queues = yield* QueueDriver; + yield* queues.producer(queueName, Schema.Unknown).publish("not-a-message"); + const first = yield* queues.processBatch(queueName, messageSchema, () => + Effect.sync(() => { + handled = true; + }), + ); + const second = yield* queues.processBatch(queueName, messageSchema, () => Effect.void); + return [first, second] as const; + }).pipe(Effect.provide(queueLayer())), + ); + + expect(counts).toEqual([1, 0]); + expect(handled).toBe(false); + }); + + it("claims each message at most once across concurrent consumers", async () => { + const queueName = `queue-concurrency-${crypto.randomUUID()}`; + const messages = Array.from({ length: 40 }, (_, sequence) => ({ sequence })); + const handled: Array = []; + + const counts = await Effect.runPromise( + Effect.gen(function* () { + const queues = yield* QueueDriver; + yield* queues.producer(queueName, messageSchema).publishBatch(messages); + return yield* Effect.all( + [ + queues.processBatch( + queueName, + messageSchema, + (batch) => + Effect.sync(() => { + handled.push(...batch.map(({ sequence }) => sequence)); + }), + { batchSize: 40 }, + ), + queues.processBatch( + queueName, + messageSchema, + (batch) => + Effect.sync(() => { + handled.push(...batch.map(({ sequence }) => sequence)); + }), + { batchSize: 40 }, + ), + ], + { concurrency: "unbounded" }, + ); + }).pipe(Effect.provide(queueLayer())), + ); + + expect(counts[0] + counts[1]).toBe(40); + expect([...handled].sort((left, right) => left - right)).toEqual( + messages.map(({ sequence }) => sequence), + ); + expect(new Set(handled).size).toBe(40); + }); + + it("runs a polling consumer until its scope closes", async () => { + const queueName = `queue-poll-${crypto.randomUUID()}`; + const delivered = Deferred.makeUnsafe>(); + + const received = await Effect.runPromise( + Effect.gen(function* () { + const queues = yield* QueueDriver; + const consumer = yield* Effect.forkChild( + queues.consumeBatch( + queueName, + messageSchema, + (batch): Effect.Effect => + Deferred.succeed(delivered, batch).pipe(Effect.asVoid), + { pollIntervalMillis: 5 }, + ), + ); + yield* Effect.yieldNow; + yield* queues.producer(queueName, messageSchema).publish({ sequence: 42 }); + const result = yield* Deferred.await(delivered).pipe(Effect.timeoutOption("2 seconds")); + const consumerExit = Option.fromNullishOr(consumer.pollUnsafe()); + yield* Fiber.interrupt(consumer); + if (Option.isNone(result) && Option.isSome(consumerExit)) { + throw new Error(`Polling consumer exited before delivery: ${String(consumerExit.value)}`); + } + return result; + }).pipe(Effect.provide(queueLayer())), + ); + + expect(Option.getOrThrow(received)).toEqual([{ sequence: 42 }]); + }); +}); diff --git a/selfhost/platform-node/tests/PgWorkflowRunner.integration.test.ts b/selfhost/platform-node/tests/PgWorkflowRunner.integration.test.ts new file mode 100644 index 00000000..bc18cda0 --- /dev/null +++ b/selfhost/platform-node/tests/PgWorkflowRunner.integration.test.ts @@ -0,0 +1,217 @@ +import { + defineWorkflow, + type WorkflowDefinition, + type WorkflowExecutionResult, + type WorkflowHandlerContext, + WorkflowRunner, + WorkflowRunnerError, +} from "@voidhash/platform/Workflow"; +import { Effect, Layer, Option, Redacted, Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; +import type { PgPlatformConfig } from "../src/Postgres.ts"; +import { PgWorkflowRunnerLive } from "../src/Workflow.ts"; + +const config: PgPlatformConfig = { + host: process.env.PLATFORM_NODE_PG_HOST ?? "127.0.0.1", + port: Number(process.env.PLATFORM_NODE_PG_PORT ?? "5432"), + database: process.env.PLATFORM_NODE_PG_DATABASE ?? "voidhash", + username: process.env.PLATFORM_NODE_PG_USERNAME ?? "voidhash", + password: Redacted.make(process.env.PLATFORM_NODE_PG_PASSWORD ?? "password"), +}; + +const runnerLayer = () => Layer.merge(PgWorkflowRunnerLive(config), NodePlatformRuntimeLive); +const describePg = process.env.PLATFORM_NODE_PG_TEST === "1" ? describe : describe.skip; + +const awaitResult = < + Name extends string, + Payload extends Schema.Struct.Fields, + Success extends Schema.Top, +>( + runner: WorkflowRunner["Service"], + workflow: WorkflowDefinition, + executionId: string, + predicate: (result: WorkflowExecutionResult) => boolean, +) => + Effect.gen(function* () { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const result = yield* runner.poll(workflow, executionId); + if (Option.isSome(result) && predicate(result.value)) { + return result.value; + } + yield* Effect.sleep("20 millis"); + } + return yield* Effect.die("workflow result timed out"); + }); + +describePg("Postgres workflow runner", () => { + it("replays completed steps and resumes a durable clock after restart", async () => { + const name = `workflow-restart-${crypto.randomUUID()}`; + const workflow = defineWorkflow({ + name, + payload: { value: Schema.Number }, + success: Schema.Number, + idempotencyKey: ({ value }) => String(value), + }); + let firstStepRuns = 0; + let secondStepRuns = 0; + const wakeAt = new Date(Date.now() + 300); + const handler = (payload: { readonly value: number }, context: WorkflowHandlerContext) => + Effect.gen(function* () { + const first = yield* context.step({ + name: "first", + success: Schema.Number, + execute: Effect.sync(() => { + firstStepRuns += 1; + return payload.value + 1; + }), + }); + yield* context.sleepUntil("restart-clock", wakeAt); + return yield* context.step({ + name: "second", + success: Schema.Number, + execute: Effect.sync(() => { + secondStepRuns += 1; + return first + 1; + }), + }); + }); + + const executionId = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + yield* runner.register(workflow, handler); + const id = yield* runner.dispatch(workflow, { value: 40 }); + yield* awaitResult( + runner, + workflow, + id, + (result) => result.status === "suspended", + ); + return id; + }).pipe(Effect.provide(runnerLayer())), + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + const value = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + yield* runner.register(workflow, handler); + return yield* runner.execute(workflow, { value: 40 }); + }).pipe(Effect.provide(runnerLayer())), + ), + ); + + expect(executionId).toBeTypeOf("string"); + expect(value).toBe(42); + expect(firstStepRuns).toBe(1); + expect(secondStepRuns).toBe(1); + }); + + it("deduplicates concurrent executions and reuses the persisted result", async () => { + const workflow = defineWorkflow({ + name: `workflow-concurrent-${crypto.randomUUID()}`, + payload: { value: Schema.Number }, + success: Schema.Number, + idempotencyKey: ({ value }) => String(value), + }); + let runs = 0; + + const execute = () => + Effect.scoped( + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + yield* runner.register(workflow, (payload, context) => + context.step({ + name: "only-step", + success: Schema.Number, + execute: Effect.sync(() => { + runs += 1; + return payload.value * 2; + }).pipe(Effect.andThen(Effect.sleep("30 millis")), Effect.as(payload.value * 2)), + }), + ); + return yield* runner.execute(workflow, { value: 21 }); + }).pipe(Effect.provide(runnerLayer())), + ); + + const values = await Effect.runPromise( + Effect.all([execute(), execute()], { concurrency: "unbounded" }), + ); + + expect(values).toEqual([42, 42]); + expect(runs).toBe(1); + }); + + it("persists a failed step through the stable workflow error channel", async () => { + const workflow = defineWorkflow({ + name: `workflow-failure-${crypto.randomUUID()}`, + payload: { value: Schema.Number }, + success: Schema.Number, + idempotencyKey: ({ value }) => String(value), + }); + + const error = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + yield* runner.register(workflow, (_payload, context) => + context.step({ + name: "failing-step", + success: Schema.Number, + execute: Effect.fail("expected failure"), + }), + ); + return yield* runner.execute(workflow, { value: 1 }).pipe(Effect.flip); + }).pipe(Effect.provide(runnerLayer())), + ), + ); + + expect(error).toBeInstanceOf(WorkflowRunnerError); + expect(error.operation).toBe("step:failing-step"); + }); + + it("persists interruption of a suspended execution", async () => { + const workflow = defineWorkflow({ + name: `workflow-interrupt-${crypto.randomUUID()}`, + payload: { value: Schema.Number }, + success: Schema.Number, + idempotencyKey: ({ value }) => String(value), + }); + + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const runner = yield* WorkflowRunner; + yield* runner.register(workflow, (payload, context) => + context + .sleepUntil("long-clock", new Date(Date.now() + 60_000)) + .pipe(Effect.as(payload.value)), + ); + const executionId = yield* runner.dispatch(workflow, { value: 1 }); + yield* awaitResult( + runner, + workflow, + executionId, + (state) => state.status === "suspended", + ); + yield* runner.interrupt(workflow, executionId); + return yield* awaitResult( + runner, + workflow, + executionId, + (state) => state.status === "interrupted", + ); + }).pipe(Effect.provide(runnerLayer())), + ), + ); + + expect(result).toEqual({ status: "interrupted" }); + }); +}); diff --git a/selfhost/platform-node/tests/S3ObjectStore.integration.test.ts b/selfhost/platform-node/tests/S3ObjectStore.integration.test.ts new file mode 100644 index 00000000..c47a118b --- /dev/null +++ b/selfhost/platform-node/tests/S3ObjectStore.integration.test.ts @@ -0,0 +1,83 @@ +import { ObjectStore, ObjectStoreError } from "@voidhash/platform/ObjectStore"; +import { Effect, Layer, Option, Redacted } from "effect"; +import { describe, expect, it } from "vitest"; + +import { S3ObjectStoreLive, type S3ObjectStoreConfig } from "../src/ObjectStore.ts"; +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; + +const config: S3ObjectStoreConfig = { + bucketName: process.env.PLATFORM_NODE_S3_BUCKET ?? "voidhash-public", + region: process.env.PLATFORM_NODE_S3_REGION ?? "us-east-1", + endpoint: process.env.PLATFORM_NODE_S3_ENDPOINT ?? "http://127.0.0.1:9000", + accessKeyId: process.env.PLATFORM_NODE_S3_ACCESS_KEY_ID ?? "voidhash", + secretAccessKey: Redacted.make( + process.env.PLATFORM_NODE_S3_SECRET_ACCESS_KEY ?? "password", + ), + forcePathStyle: true, +}; + +const storeLayer = (input: S3ObjectStoreConfig = config) => + Layer.merge(S3ObjectStoreLive(input), NodePlatformRuntimeLive); +const describeS3 = process.env.PLATFORM_NODE_S3_TEST === "1" ? describe : describe.skip; + +describeS3("S3-compatible object store", () => { + it("writes, reads, heads, overwrites, and deletes objects", async () => { + const key = `tests/${crypto.randomUUID()}.txt`; + const initial = new TextEncoder().encode("first value"); + const replacement = new TextEncoder().encode("replacement value"); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const store = yield* ObjectStore; + const missing = yield* store.get(key); + yield* store.put({ key, body: initial, contentType: "text/plain" }); + const stored = yield* store.get(key); + const head = yield* store.head(key); + yield* store.put({ key, body: replacement, contentType: "text/custom" }); + const overwritten = yield* store.get(key); + yield* store.delete(key); + yield* store.delete(key); + const deleted = yield* store.head(key); + return { missing, stored, head, overwritten, deleted }; + }).pipe(Effect.provide(storeLayer())), + ); + + expect(Option.isNone(result.missing)).toBe(true); + expect(Option.getOrThrow(result.stored)).toMatchObject({ + body: initial, + contentType: "text/plain", + size: initial.byteLength, + }); + expect(Option.getOrThrow(result.head)).toMatchObject({ + contentType: "text/plain", + size: initial.byteLength, + }); + expect(Option.getOrThrow(result.overwritten)).toMatchObject({ + body: replacement, + contentType: "text/custom", + size: replacement.byteLength, + }); + expect(Option.isNone(result.deleted)).toBe(true); + }); + + it("maps bucket failures to the stable object-store error", async () => { + const missingBucket = `${config.bucketName}-missing-${crypto.randomUUID()}`; + const error = await Effect.runPromise( + Effect.gen(function* () { + const store = yield* ObjectStore; + return yield* store.get("missing").pipe(Effect.flip); + }).pipe( + Effect.provide( + storeLayer({ + ...config, + bucketName: missingBucket, + }), + ), + ), + ); + + expect(error).toBeInstanceOf(ObjectStoreError); + expect(error.bucketName).toBe(missingBucket); + expect(error.operation).toBe("get"); + }); +}); diff --git a/selfhost/platform-node/tests/Screenshot.test.ts b/selfhost/platform-node/tests/Screenshot.test.ts new file mode 100644 index 00000000..56a24f29 --- /dev/null +++ b/selfhost/platform-node/tests/Screenshot.test.ts @@ -0,0 +1,42 @@ +import { ScreenshotError } from "@voidhash/platform/Screenshot"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { validateChromiumScreenshotOptions } from "../src/Screenshot.ts"; + +const normalScreenshot = { + deviceScaleFactor: 2, + height: 812, + html: "

Paywall

", + width: 375, +}; + +describe("Chromium screenshot budgets", () => { + it("accepts a normal phone-sized render", async () => { + await expect( + Effect.runPromise(validateChromiumScreenshotOptions(normalScreenshot, {})), + ).resolves.toBeUndefined(); + }); + + it("rejects HTML over the configured byte budget", async () => { + const error = await Effect.runPromise( + validateChromiumScreenshotOptions( + { ...normalScreenshot, html: "12345" }, + { maxHtmlBytes: 4 }, + ).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(ScreenshotError); + expect(error.operation).toBe("validate"); + }); + + it("rejects a viewport and scale combination over the pixel budget", async () => { + const error = await Effect.runPromise( + validateChromiumScreenshotOptions( + { ...normalScreenshot, deviceScaleFactor: 4, height: 4_096, width: 4_096 }, + {}, + ).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(ScreenshotError); + expect(error.operation).toBe("validate"); + }); +}); diff --git a/selfhost/platform-node/tests/SmtpMailer.integration.test.ts b/selfhost/platform-node/tests/SmtpMailer.integration.test.ts new file mode 100644 index 00000000..132270f1 --- /dev/null +++ b/selfhost/platform-node/tests/SmtpMailer.integration.test.ts @@ -0,0 +1,134 @@ +import { Mailer, MailerError } from "@voidhash/platform/Mailer"; +import { Effect, Layer } from "effect"; +import { describe, expect, it } from "vitest"; + +import { SmtpMailerLive, type SmtpMailerConfig } from "../src/Mailer.ts"; +import { NodePlatformRuntimeLive } from "../src/PlatformRuntime.ts"; + +const config: SmtpMailerConfig = { + host: process.env.PLATFORM_NODE_SMTP_HOST ?? "127.0.0.1", + port: Number(process.env.PLATFORM_NODE_SMTP_PORT ?? "1025"), + defaultFrom: { address: "noreply@voidhash.local", name: "Voidhash" }, + verifyOnStart: true, +}; + +const mailerLayer = (input: SmtpMailerConfig = config) => + Layer.merge(SmtpMailerLive(input), NodePlatformRuntimeLive); +const describeSmtp = process.env.PLATFORM_NODE_SMTP_TEST === "1" ? describe : describe.skip; +const mailpitApi = process.env.PLATFORM_NODE_MAILPIT_API ?? "http://127.0.0.1:8025"; + +interface MailpitMessageSummary { + readonly ID: string; + readonly Subject: string; +} + +interface MailpitMessages { + readonly messages: ReadonlyArray; +} + +interface MailpitMessage { + readonly From: { readonly Address: string }; + readonly To: ReadonlyArray<{ readonly Address: string }>; + readonly ReplyTo: ReadonlyArray<{ readonly Address: string }>; + readonly Text: string; + readonly HTML: string; +} + +describeSmtp("SMTP mailer", () => { + it("delivers structured text and HTML through SMTP", async () => { + const subject = `SMTP integration ${crypto.randomUUID()}`; + const delivery = await Effect.runPromise( + Effect.gen(function* () { + const mailer = yield* Mailer; + return yield* mailer.send({ + to: [{ address: "person@example.com", name: "Example Person" }], + replyTo: { address: "support@voidhash.local", name: "Support" }, + subject, + text: "Plain body", + html: "HTML body", + headers: { "X-Voidhash-Test": "smtp" }, + }); + }).pipe(Effect.provide(mailerLayer())), + ); + + expect(delivery.messageId).toBeTypeOf("string"); + expect(delivery.accepted).toContain("person@example.com"); + expect(delivery.rejected).toEqual([]); + + const messagesResponse = await fetch(`${mailpitApi}/api/v1/messages`); + expect(messagesResponse.ok).toBe(true); + const messages = (await messagesResponse.json()) as MailpitMessages; + const messageId = messages.messages.find((message) => message.Subject === subject)?.ID; + expect(messageId).toBeTypeOf("string"); + + const [messageResponse, headersResponse] = await Promise.all([ + fetch(`${mailpitApi}/api/v1/message/${messageId}`), + fetch(`${mailpitApi}/api/v1/message/${messageId}/headers`), + ]); + expect(messageResponse.ok).toBe(true); + expect(headersResponse.ok).toBe(true); + + const message = (await messageResponse.json()) as MailpitMessage; + const headers = (await headersResponse.json()) as Record>; + expect(message.From.Address).toBe("noreply@voidhash.local"); + expect(message.To.map(({ Address }) => Address)).toEqual(["person@example.com"]); + expect(message.ReplyTo.map(({ Address }) => Address)).toEqual(["support@voidhash.local"]); + expect(message.Text).toBe("Plain body"); + expect(message.HTML).toBe("HTML body"); + expect(headers["X-Voidhash-Test"]).toEqual(["smtp"]); + }); + + it("validates message content through the stable error channel", async () => { + const error = await Effect.runPromise( + Effect.gen(function* () { + const mailer = yield* Mailer; + return yield* mailer + .send({ to: [], subject: "invalid" }) + .pipe(Effect.flip); + }).pipe(Effect.provide(mailerLayer({ ...config, verifyOnStart: false }))), + ); + + expect(error).toBeInstanceOf(MailerError); + expect(error.operation).toBe("validate"); + }); + + it("maps SMTP connection failures during startup verification", async () => { + const error = await Effect.runPromise( + Effect.gen(function* () { + return yield* Mailer; + }).pipe( + Effect.provide( + mailerLayer({ + ...config, + port: 1, + connectionTimeoutMillis: 200, + }), + ), + Effect.flip, + ), + ); + + expect(error).toBeInstanceOf(MailerError); + expect(error.operation).toBe("verify"); + }); + + it("rejects incomplete SMTP authentication configuration", async () => { + const error = await Effect.runPromise( + Effect.gen(function* () { + return yield* Mailer; + }).pipe( + Effect.provide( + mailerLayer({ + ...config, + username: "user", + password: undefined, + }), + ), + Effect.flip, + ), + ); + + expect(error).toBeInstanceOf(MailerError); + expect(error.operation).toBe("configure"); + }); +}); diff --git a/selfhost/platform-node/tsconfig.json b/selfhost/platform-node/tsconfig.json new file mode 100644 index 00000000..f040f8ac --- /dev/null +++ b/selfhost/platform-node/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@voidhash/tsconfig/typescript-6.json", + "compilerOptions": { + "types": ["node"], + "noEmit": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true + }, + "include": ["src", "tests", "vitest.mts"], + "exclude": ["**/node_modules/**"] +} diff --git a/selfhost/platform-node/vitest.mts b/selfhost/platform-node/vitest.mts new file mode 100644 index 00000000..e3ed6a26 --- /dev/null +++ b/selfhost/platform-node/vitest.mts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + test: { + environment: "node", + include: ["./tests/**/*.test.ts"], + exclude: ["./node_modules/**"], + reporters: ["verbose"], + }, +}); From 173261b7c1df5500ca1a95509587fba0c079703a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Wed, 5 Aug 2026 11:19:55 +0200 Subject: [PATCH 2/7] refactor(www): move the marketing and design sites to the host build The landing page and the design-system site are cloud-company surfaces rather than open-core product surface, so they move to the host repository and this app keeps only what a self-hosted deployment needs. - `/` now hands off to the studio. The route shell stays here because a host cannot add a second `/` route without colliding with this one; instead `_marketing/index.tsx` renders `features/www/marketing-home-slot`, which hosts replace the same way as the other extension points. - `features/www/landing` and the whole `features/design` + `routes/design` tree move out. `hero-shader` stays as `components/hero-shader` because the auth pages use it, and `lib/waitlist` / `lib/paths` stay because the studio and auth flows depend on them. - `source.config.ts` keeps only the product-docs collection. A host that adds documentation surfaces replaces this config wholesale, since two `mdx()` instances would clobber each other's `.source` output. - Drop `features/www/{button,logo,refraction-stripes,section,navbar,hero}` and the `voidhash-gradient-*` cluster, which were reachable only from the unreferenced `hero/hero.tsx` that this change removes. Co-Authored-By: Claude Opus 5 (1M context) --- apps/www/.source/browser.ts | 7 - apps/www/.source/server.ts | 15 - .../www/hero => components}/hero-shader.tsx | 0 .../voidhash-gradient-background.tsx | 224 ---- .../components/voidhash-gradient-canvas.tsx | 328 ----- .../components/voidhash-gradient-controls.tsx | 175 --- .../components/voidhash-gradient-settings.ts | 102 -- .../components/auth-lenticular-background.tsx | 2 +- .../design/components/docs/colors/index.tsx | 282 ----- .../design/components/docs/colors/tokens.ts | 452 ------- .../components/docs/component-overview.tsx | 14 - .../cards/account-access.tsx | 86 -- .../cards/card-overview.tsx | 77 -- .../cards/claimable-balance.tsx | 51 - .../cards/contribution-history.tsx | 92 -- .../component-overview/cards/cover-art.tsx | 48 - .../cards/dividend-income.tsx | 123 -- .../cards/empty-connect-bank.tsx | 40 - .../cards/empty-distribute-track.tsx | 41 - .../cards/empty-explore-catalog.tsx | 40 - .../docs/component-overview/cards/faq.tsx | 103 -- .../component-overview/cards/front-door.tsx | 41 - .../cards/index-investing.tsx | 22 - .../cards/kitchen-island.tsx | 161 --- .../component-overview/cards/loading-card.tsx | 25 - .../cards/new-milestone.tsx | 50 - .../cards/notification-settings.tsx | 98 -- .../component-overview/cards/payments.tsx | 169 --- .../cards/payout-threshold.tsx | 101 -- .../component-overview/cards/power-usage.tsx | 81 -- .../component-overview/cards/preferences.tsx | 92 -- .../cards/project-actions.tsx | 96 -- .../component-overview/cards/qr-connect.tsx | 36 - .../cards/receiving-method.tsx | 90 -- .../cards/recent-transactions.tsx | 276 ----- .../cards/release-catalog.tsx | 99 -- .../cards/roller-shades.tsx | 69 -- .../cards/savings-progress.tsx | 93 -- .../cards/savings-targets.tsx | 116 -- .../component-overview/cards/sidebar-nav.tsx | 285 ----- .../component-overview/cards/social-links.tsx | 85 -- .../cards/stock-performance.tsx | 119 -- .../cards/syncing-state.tsx | 34 - .../cards/transfer-funds.tsx | 107 -- .../cards/upcoming-payments.tsx | 54 - .../component-overview/icon-placeholder.tsx | 31 - .../docs/component-overview/index.tsx | 100 -- .../docs/component-overview/preview-config.ts | 10 - .../components/docs/component-registry.tsx | 34 - .../design/components/docs/preview.tsx | 24 - .../design/components/layout/docs.tsx | 227 ---- .../design/components/layout/page.tsx | 126 -- .../features/design/components/nav-bar.tsx | 66 - .../design/components/theme-toggle.tsx | 15 - .../content/docs/components/accordion.mdx | 115 -- .../content/docs/components/alert-dialog.mdx | 119 -- .../design/content/docs/components/alert.mdx | 85 -- .../design/content/docs/components/avatar.mdx | 110 -- .../design/content/docs/components/badge.mdx | 89 -- .../content/docs/components/breadcrumb.mdx | 132 -- .../design/content/docs/components/button.mdx | 184 --- .../content/docs/components/calendar.mdx | 93 -- .../design/content/docs/components/card.mdx | 153 --- .../content/docs/components/carousel.mdx | 126 -- .../design/content/docs/components/chart.mdx | 138 --- .../content/docs/components/checkbox.mdx | 105 -- .../content/docs/components/collapsible.mdx | 101 -- .../content/docs/components/command.mdx | 159 --- .../content/docs/components/context-menu.mdx | 127 -- .../design/content/docs/components/dialog.mdx | 156 --- .../design/content/docs/components/drawer.mdx | 169 --- .../content/docs/components/dropdown-menu.mdx | 195 --- .../content/docs/components/dropzone.mdx | 102 -- .../design/content/docs/components/form.mdx | 186 --- .../content/docs/components/hover-card.mdx | 137 --- .../content/docs/components/input-otp.mdx | 136 --- .../design/content/docs/components/input.mdx | 113 -- .../design/content/docs/components/label.mdx | 81 -- .../content/docs/components/menubar.mdx | 136 --- .../design/content/docs/components/meta.yaml | 53 - .../docs/components/navigation-menu.mdx | 156 --- .../content/docs/components/overview.mdx | 7 - .../content/docs/components/page-bar.mdx | 57 - .../content/docs/components/pagination.mdx | 146 --- .../design/content/docs/components/phone.mdx | 57 - .../content/docs/components/popover.mdx | 146 --- .../content/docs/components/progress.mdx | 92 -- .../content/docs/components/radio-group.mdx | 146 --- .../content/docs/components/resizable.mdx | 144 --- .../content/docs/components/scroll-area.mdx | 109 -- .../design/content/docs/components/select.mdx | 185 --- .../content/docs/components/separator.mdx | 84 -- .../design/content/docs/components/sheet.mdx | 177 --- .../content/docs/components/sidebar.mdx | 179 --- .../content/docs/components/skeleton.mdx | 91 -- .../design/content/docs/components/slider.mdx | 105 -- .../design/content/docs/components/sonner.mdx | 120 -- .../design/content/docs/components/switch.mdx | 105 -- .../design/content/docs/components/table.mdx | 148 --- .../design/content/docs/components/tabs.mdx | 126 -- .../content/docs/components/textarea.mdx | 96 -- .../content/docs/components/toggle-group.mdx | 136 --- .../design/content/docs/components/toggle.mdx | 146 --- .../content/docs/components/tooltip.mdx | 140 --- .../docs/components/underline-tabs.mdx | 97 -- .../content/docs/foundations/colors.mdx | 116 -- .../design/content/docs/foundations/meta.yaml | 4 - .../design/content/docs/introduction.mdx | 4 - .../features/design/content/docs/meta.yaml | 6 - apps/www/src/features/design/lib/basepath.ts | 1 - apps/www/src/features/design/lib/cn.ts | 1 - .../src/features/design/lib/layout.shared.tsx | 8 - apps/www/src/features/design/lib/source.ts | 35 - apps/www/src/features/design/source.config.ts | 30 - .../src/features/design/styles/globals.css | 32 - apps/www/src/features/source.config.ts | 19 +- apps/www/src/features/www/button.tsx | 58 - apps/www/src/features/www/hero/footer.tsx | 61 - apps/www/src/features/www/hero/hero.tsx | 72 -- .../assets/agent-icons/antigravity.png | Bin 9026 -> 0 bytes .../assets/agent-icons/claude-code.png | Bin 21744 -> 0 bytes .../www/landing/assets/agent-icons/codex.png | Bin 17775 -> 0 bytes .../www/landing/assets/agent-icons/cursor.png | Bin 6164 -> 0 bytes .../landing/assets/agent-icons/opencode.png | Bin 10326 -> 0 bytes .../features/www/landing/asteroids/engine.ts | 1088 ----------------- .../features/www/landing/asteroids/frame.ts | 7 - .../features/www/landing/asteroids/game.tsx | 444 ------- .../features/www/landing/asteroids/post.ts | 209 ---- .../src/features/www/landing/landing-page.tsx | 43 - .../www/landing/marketing-nav-config.ts | 14 - apps/www/src/features/www/landing/motion.tsx | 236 ---- .../paywall-build/agent-transcript.tsx | 163 --- .../landing/paywall-build/lenscal-paywall.tsx | 356 ------ .../landing/paywall-build/paywall-build.tsx | 102 -- .../www/landing/paywall-build/transcript.ts | 125 -- .../paywall-build/use-transcript-playback.ts | 118 -- .../paywall-build/working-indicator.tsx | 147 --- .../landing/sections/agent-compatibility.tsx | 57 - .../www/landing/sections/analytics.tsx | 531 -------- .../www/landing/sections/asteroids.tsx | 161 --- .../src/features/www/landing/sections/crm.tsx | 337 ----- .../www/landing/sections/developers.tsx | 168 --- .../features/www/landing/sections/divider.tsx | 8 - .../www/landing/sections/experimentation.tsx | 221 ---- .../features/www/landing/sections/footer.tsx | 134 -- .../www/landing/sections/get-started.tsx | 117 -- .../features/www/landing/sections/hero.tsx | 52 - .../features/www/landing/sections/navbar.tsx | 216 ---- .../www/landing/sections/paywalls.tsx | 576 --------- .../features/www/landing/sections/problem.tsx | 19 - apps/www/src/features/www/landing/shared.tsx | 151 --- apps/www/src/features/www/logo.tsx | 92 -- .../src/features/www/marketing-home-slot.tsx | 20 + .../src/features/www/navbar/navigation.tsx | 49 - .../src/features/www/refraction-stripes.tsx | 38 - .../www/section/section-container.tsx | 15 - apps/www/src/routeTree.gen.ts | 92 -- apps/www/src/routes/_marketing/index.tsx | 17 +- apps/www/src/routes/design/$.tsx | 174 --- apps/www/src/routes/design/api/search.ts | 19 - apps/www/src/routes/design/index.tsx | 13 - apps/www/src/routes/design/route.tsx | 42 - 162 files changed, 29 insertions(+), 18418 deletions(-) rename apps/www/src/{features/www/hero => components}/hero-shader.tsx (100%) delete mode 100644 apps/www/src/components/voidhash-gradient-background.tsx delete mode 100644 apps/www/src/components/voidhash-gradient-canvas.tsx delete mode 100644 apps/www/src/components/voidhash-gradient-controls.tsx delete mode 100644 apps/www/src/components/voidhash-gradient-settings.ts delete mode 100644 apps/www/src/features/design/components/docs/colors/index.tsx delete mode 100644 apps/www/src/features/design/components/docs/colors/tokens.ts delete mode 100644 apps/www/src/features/design/components/docs/component-overview.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/account-access.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/card-overview.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/claimable-balance.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/contribution-history.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/cover-art.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/dividend-income.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/empty-connect-bank.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/empty-distribute-track.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/empty-explore-catalog.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/front-door.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/index-investing.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/kitchen-island.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/loading-card.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/new-milestone.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/notification-settings.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/payments.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/payout-threshold.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/power-usage.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/preferences.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/project-actions.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/qr-connect.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/receiving-method.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/recent-transactions.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/release-catalog.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/roller-shades.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/savings-progress.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/savings-targets.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/sidebar-nav.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/social-links.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/stock-performance.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/syncing-state.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/transfer-funds.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/cards/upcoming-payments.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/icon-placeholder.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/index.tsx delete mode 100644 apps/www/src/features/design/components/docs/component-overview/preview-config.ts delete mode 100644 apps/www/src/features/design/components/docs/component-registry.tsx delete mode 100644 apps/www/src/features/design/components/docs/preview.tsx delete mode 100644 apps/www/src/features/design/components/layout/docs.tsx delete mode 100644 apps/www/src/features/design/components/layout/page.tsx delete mode 100644 apps/www/src/features/design/components/nav-bar.tsx delete mode 100644 apps/www/src/features/design/components/theme-toggle.tsx delete mode 100644 apps/www/src/features/design/content/docs/components/accordion.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/alert-dialog.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/alert.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/avatar.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/badge.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/breadcrumb.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/button.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/calendar.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/card.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/carousel.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/chart.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/checkbox.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/collapsible.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/command.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/context-menu.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/dialog.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/drawer.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/dropdown-menu.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/dropzone.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/form.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/hover-card.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/input-otp.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/input.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/label.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/menubar.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/meta.yaml delete mode 100644 apps/www/src/features/design/content/docs/components/navigation-menu.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/overview.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/page-bar.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/pagination.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/phone.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/popover.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/progress.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/radio-group.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/resizable.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/scroll-area.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/select.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/separator.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/sheet.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/sidebar.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/skeleton.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/slider.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/sonner.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/switch.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/table.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/tabs.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/textarea.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/toggle-group.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/toggle.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/tooltip.mdx delete mode 100644 apps/www/src/features/design/content/docs/components/underline-tabs.mdx delete mode 100644 apps/www/src/features/design/content/docs/foundations/colors.mdx delete mode 100644 apps/www/src/features/design/content/docs/foundations/meta.yaml delete mode 100644 apps/www/src/features/design/content/docs/introduction.mdx delete mode 100644 apps/www/src/features/design/content/docs/meta.yaml delete mode 100644 apps/www/src/features/design/lib/basepath.ts delete mode 100644 apps/www/src/features/design/lib/cn.ts delete mode 100644 apps/www/src/features/design/lib/layout.shared.tsx delete mode 100644 apps/www/src/features/design/lib/source.ts delete mode 100644 apps/www/src/features/design/source.config.ts delete mode 100644 apps/www/src/features/design/styles/globals.css delete mode 100644 apps/www/src/features/www/button.tsx delete mode 100644 apps/www/src/features/www/hero/footer.tsx delete mode 100644 apps/www/src/features/www/hero/hero.tsx delete mode 100644 apps/www/src/features/www/landing/assets/agent-icons/antigravity.png delete mode 100644 apps/www/src/features/www/landing/assets/agent-icons/claude-code.png delete mode 100644 apps/www/src/features/www/landing/assets/agent-icons/codex.png delete mode 100644 apps/www/src/features/www/landing/assets/agent-icons/cursor.png delete mode 100644 apps/www/src/features/www/landing/assets/agent-icons/opencode.png delete mode 100644 apps/www/src/features/www/landing/asteroids/engine.ts delete mode 100644 apps/www/src/features/www/landing/asteroids/frame.ts delete mode 100644 apps/www/src/features/www/landing/asteroids/game.tsx delete mode 100644 apps/www/src/features/www/landing/asteroids/post.ts delete mode 100644 apps/www/src/features/www/landing/landing-page.tsx delete mode 100644 apps/www/src/features/www/landing/marketing-nav-config.ts delete mode 100644 apps/www/src/features/www/landing/motion.tsx delete mode 100644 apps/www/src/features/www/landing/paywall-build/agent-transcript.tsx delete mode 100644 apps/www/src/features/www/landing/paywall-build/lenscal-paywall.tsx delete mode 100644 apps/www/src/features/www/landing/paywall-build/paywall-build.tsx delete mode 100644 apps/www/src/features/www/landing/paywall-build/transcript.ts delete mode 100644 apps/www/src/features/www/landing/paywall-build/use-transcript-playback.ts delete mode 100644 apps/www/src/features/www/landing/paywall-build/working-indicator.tsx delete mode 100644 apps/www/src/features/www/landing/sections/agent-compatibility.tsx delete mode 100644 apps/www/src/features/www/landing/sections/analytics.tsx delete mode 100644 apps/www/src/features/www/landing/sections/asteroids.tsx delete mode 100644 apps/www/src/features/www/landing/sections/crm.tsx delete mode 100644 apps/www/src/features/www/landing/sections/developers.tsx delete mode 100644 apps/www/src/features/www/landing/sections/divider.tsx delete mode 100644 apps/www/src/features/www/landing/sections/experimentation.tsx delete mode 100644 apps/www/src/features/www/landing/sections/footer.tsx delete mode 100644 apps/www/src/features/www/landing/sections/get-started.tsx delete mode 100644 apps/www/src/features/www/landing/sections/hero.tsx delete mode 100644 apps/www/src/features/www/landing/sections/navbar.tsx delete mode 100644 apps/www/src/features/www/landing/sections/paywalls.tsx delete mode 100644 apps/www/src/features/www/landing/sections/problem.tsx delete mode 100644 apps/www/src/features/www/landing/shared.tsx delete mode 100644 apps/www/src/features/www/logo.tsx create mode 100644 apps/www/src/features/www/marketing-home-slot.tsx delete mode 100644 apps/www/src/features/www/navbar/navigation.tsx delete mode 100644 apps/www/src/features/www/refraction-stripes.tsx delete mode 100644 apps/www/src/features/www/section/section-container.tsx delete mode 100644 apps/www/src/routes/design/$.tsx delete mode 100644 apps/www/src/routes/design/api/search.ts delete mode 100644 apps/www/src/routes/design/index.tsx delete mode 100644 apps/www/src/routes/design/route.tsx diff --git a/apps/www/.source/browser.ts b/apps/www/.source/browser.ts index a20e34d7..f8c66bc1 100644 --- a/apps/www/.source/browser.ts +++ b/apps/www/.source/browser.ts @@ -15,12 +15,5 @@ const browserCollections = { }, "eager": false })), - design: create.doc("design", import.meta.glob(["./**/*.{mdx,md}"], { - "base": "./../src/features/design/content/docs", - "query": { - "collection": "design" - }, - "eager": false - })), }; export default browserCollections; \ No newline at end of file diff --git a/apps/www/.source/server.ts b/apps/www/.source/server.ts index 25501242..0b72d6d6 100644 --- a/apps/www/.source/server.ts +++ b/apps/www/.source/server.ts @@ -21,19 +21,4 @@ export const docs = await create.docs("docs", "src/features/docs/content/docs", "collection": "docs" }, "eager": true -})); - -export const design = await create.docs("design", "src/features/design/content/docs", import.meta.glob(["./**/*.{json,yaml}"], { - "base": "./../src/features/design/content/docs", - "query": { - "collection": "design" - }, - "import": "default", - "eager": true -}), import.meta.glob(["./**/*.{mdx,md}"], { - "base": "./../src/features/design/content/docs", - "query": { - "collection": "design" - }, - "eager": true })); \ No newline at end of file diff --git a/apps/www/src/features/www/hero/hero-shader.tsx b/apps/www/src/components/hero-shader.tsx similarity index 100% rename from apps/www/src/features/www/hero/hero-shader.tsx rename to apps/www/src/components/hero-shader.tsx diff --git a/apps/www/src/components/voidhash-gradient-background.tsx b/apps/www/src/components/voidhash-gradient-background.tsx deleted file mode 100644 index 9450994f..00000000 --- a/apps/www/src/components/voidhash-gradient-background.tsx +++ /dev/null @@ -1,224 +0,0 @@ -"use client"; - -import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type CSSProperties } from "react"; - -import { cn } from "@/lib/utils"; - -import { VoidhashGradientControls } from "./voidhash-gradient-controls"; -import { - DEFAULT_VOIDHASH_GRADIENT_SETTINGS, - mergeVoidhashGradientSettings, - VOIDHASH_GRADIENT_CONTROLS_STORAGE_KEY, - VOIDHASH_GRADIENT_STORAGE_KEY, - type VoidhashGradientSettings, -} from "./voidhash-gradient-settings"; - -const VoidhashGradientCanvas = lazy(async () => { - const module = await import("./voidhash-gradient-canvas"); - - return { - default: module.VoidhashGradientCanvas, - }; -}); - -type VoidhashGradientPlacement = "bottom" | "top"; -type ReadyPlacements = Record; - -function getFadeMask(settings: VoidhashGradientSettings, placement: VoidhashGradientPlacement) { - const start = Math.min(settings.fadeStart, settings.fadeEnd - 1); - const end = Math.max(settings.fadeEnd, start + 1); - const mid = start + (end - start) * 0.35; - const direction = placement === "top" ? "to top" : "to bottom"; - - return `linear-gradient(${direction}, transparent 0%, transparent ${start}%, rgba(0, 0, 0, 0.12) ${mid}%, black ${end}%, black 100%)`; -} - -function getLayerStyle(settings: VoidhashGradientSettings, placement: VoidhashGradientPlacement): CSSProperties { - return { - [placement]: `${placement === "top" ? settings.topOffset : settings.bottomOffset}%`, - filter: `blur(${settings.blur}px)`, - height: `${settings.effectHeight}%`, - maskImage: getFadeMask(settings, placement), - WebkitMaskImage: getFadeMask(settings, placement), - }; -} - -function VoidhashGradientEntryVeil({ isVisible }: { isVisible: boolean }) { - return ( -
- ); -} - -function VoidhashGradientLayer({ - isRevealed, - onReady, - placement, - settings, -}: { - isRevealed: boolean; - onReady: (placement: VoidhashGradientPlacement) => void; - placement: VoidhashGradientPlacement; - settings: VoidhashGradientSettings; -}) { - const [isCanvasReady, setIsCanvasReady] = useState(false); - - return ( -
-
- { - setIsCanvasReady(true); - onReady(placement); - }} - seed={placement === "top" ? settings.topSeed : settings.bottomSeed} - settings={settings} - /> -
-
- ); -} - -export type VoidhashGradientBackgroundProps = { - className?: string; - controlsQueryParam?: string; - controlsStorageKey?: string; - controlsTitle?: string; - settings?: Partial; - settingsStorageKey?: string; -}; - -/** Renders the reusable blurred Three.js gradient background used across Voidhash surfaces. */ -export function VoidhashGradientBackground({ - className, - controlsQueryParam = "gradientControls", - controlsStorageKey = VOIDHASH_GRADIENT_CONTROLS_STORAGE_KEY, - controlsTitle = "Gradient FX", - settings: settingsProp, - settingsStorageKey = VOIDHASH_GRADIENT_STORAGE_KEY, -}: VoidhashGradientBackgroundProps) { - const baseSettings = useMemo( - () => mergeVoidhashGradientSettings(settingsProp ?? DEFAULT_VOIDHASH_GRADIENT_SETTINGS), - [settingsProp], - ); - const [isMounted, setIsMounted] = useState(false); - const [canDropEntryVeil, setCanDropEntryVeil] = useState(false); - const [hasRevealed, setHasRevealed] = useState(false); - const [readyPlacements, setReadyPlacements] = useState({ bottom: false, top: false }); - const [settings, setSettings] = useState(baseSettings); - const [showControls, setShowControls] = useState(false); - - useEffect(() => { - setIsMounted(true); - - const params = new URLSearchParams(window.location.search); - const controlsEnabled = params.has(controlsQueryParam) || localStorage.getItem(controlsStorageKey) === "1"; - const savedSettings = localStorage.getItem(settingsStorageKey); - - if (savedSettings) { - try { - setSettings(mergeVoidhashGradientSettings(JSON.parse(savedSettings), baseSettings)); - } catch { - localStorage.removeItem(settingsStorageKey); - } - } - - if (controlsEnabled) { - setShowControls(true); - localStorage.setItem(controlsStorageKey, "1"); - } - }, [baseSettings, controlsQueryParam, controlsStorageKey, settingsStorageKey]); - - useEffect(() => { - if (!isMounted) { - return; - } - - const veilTimer = window.setTimeout(() => setCanDropEntryVeil(true), 1800); - - return () => window.clearTimeout(veilTimer); - }, [isMounted]); - - useEffect(() => { - if (isMounted && showControls) { - localStorage.setItem(settingsStorageKey, JSON.stringify(settings)); - } - }, [isMounted, settings, settingsStorageKey, showControls]); - - const handleLayerReady = useCallback((placement: VoidhashGradientPlacement) => { - setReadyPlacements((current) => { - if (current[placement]) { - return current; - } - - return { ...current, [placement]: true }; - }); - }, []); - - const isShaderReady = readyPlacements.bottom && (!settings.topEnabled || readyPlacements.top); - - useEffect(() => { - if (!hasRevealed && isMounted && (isShaderReady || canDropEntryVeil)) { - setHasRevealed(true); - } - }, [canDropEntryVeil, hasRevealed, isMounted, isShaderReady]); - - const rootClassName = cn("relative isolate h-full w-full overflow-hidden bg-black", className); - - if (!isMounted) { - return ( -
- -
- ); - } - - return ( -
- - - {settings.topEnabled ? ( - - ) : null} - - - - {showControls ? ( - { - setShowControls(false); - localStorage.removeItem(controlsStorageKey); - }} - onReset={() => { - setSettings(baseSettings); - localStorage.removeItem(settingsStorageKey); - }} - settings={settings} - title={controlsTitle} - /> - ) : null} -
- ); -} diff --git a/apps/www/src/components/voidhash-gradient-canvas.tsx b/apps/www/src/components/voidhash-gradient-canvas.tsx deleted file mode 100644 index 3c437287..00000000 --- a/apps/www/src/components/voidhash-gradient-canvas.tsx +++ /dev/null @@ -1,328 +0,0 @@ -"use client"; - -import { Canvas, useFrame } from "@react-three/fiber"; -import { useEffect, useMemo, useRef } from "react"; -import * as THREE from "three"; - -import type { VoidhashGradientSettings } from "./voidhash-gradient-settings"; - -const vertexShader = ` -uniform float uTime; -uniform float uAmplitude; -uniform float uFrequency; -uniform float uMidFrequency; -uniform float uHighFrequency; -uniform float uLift; -uniform float uSeed; - -varying vec2 vUv; -varying vec3 vNormal; -varying vec3 vViewPosition; -varying float vWave; - -vec4 mod289(vec4 x) { - return x - floor(x * (1.0 / 289.0)) * 289.0; -} - -vec3 mod289(vec3 x) { - return x - floor(x * (1.0 / 289.0)) * 289.0; -} - -vec4 permute(vec4 x) { - return mod289(((x * 34.0) + 10.0) * x); -} - -vec4 taylorInvSqrt(vec4 r) { - return 1.79284291400159 - 0.85373472095314 * r; -} - -float snoise(vec3 v) { - const vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); - const vec4 d = vec4(0.0, 0.5, 1.0, 2.0); - - vec3 i = floor(v + dot(v, c.yyy)); - vec3 x0 = v - i + dot(i, c.xxx); - - vec3 g = step(x0.yzx, x0.xyz); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - - vec3 x1 = x0 - i1 + c.xxx; - vec3 x2 = x0 - i2 + c.yyy; - vec3 x3 = x0 - d.yyy; - - i = mod289(i); - vec4 p = permute(permute(permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) - + i.y + vec4(0.0, i1.y, i2.y, 1.0)) - + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - - float n_ = 0.142857142857; - vec3 ns = n_ * d.wyz - d.xzx; - - vec4 j = p - 49.0 * floor(p * ns.z * ns.z); - - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - - vec4 x = x_ * ns.x + ns.yyyy; - vec4 y = y_ * ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - - vec4 s0 = floor(b0) * 2.0 + 1.0; - vec4 s1 = floor(b1) * 2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0)); - - vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; - - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - - vec4 norm = taylorInvSqrt(vec4( - dot(p0, p0), - dot(p1, p1), - dot(p2, p2), - dot(p3, p3) - )); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - vec4 m = max(0.6 - vec4( - dot(x0, x0), - dot(x1, x1), - dot(x2, x2), - dot(x3, x3) - ), 0.0); - m = m * m; - - return 42.0 * dot(m * m, vec4( - dot(p0, x0), - dot(p1, x1), - dot(p2, x2), - dot(p3, x3) - )); -} - -float surfaceHeight(vec2 p, float time) { - vec2 seedOffset = vec2(uSeed * 1.37, uSeed * -0.73); - vec2 q = vec2(p.x * uFrequency, p.y * uFrequency * 2.55) + seedOffset; - float seedTime = time + uSeed * 0.11; - float low = snoise(vec3(q, seedTime)); - float mid = snoise(vec3(q * uMidFrequency + vec2(7.4, -3.2) + seedOffset * 0.43, seedTime * 1.65)); - float high = snoise(vec3(q * uHighFrequency + vec2(-2.0, 5.7) - seedOffset * 0.26, seedTime * 2.4)); - - return (low * 0.62 + mid * 0.28 + high * 0.1) * uAmplitude; -} - -void main() { - vUv = uv; - - float lift = smoothstep(0.02, 0.92, uv.y); - float height = surfaceHeight(position.xy, uTime) * (0.72 + lift * 0.62); - float epsilon = 0.08; - float heightX = surfaceHeight(position.xy + vec2(epsilon, 0.0), uTime); - float heightY = surfaceHeight(position.xy + vec2(0.0, epsilon), uTime); - - vec3 displaced = position; - displaced.y += lift * uLift; - displaced.z += height; - - vec3 displacedNormal = normalize(vec3( - (height - heightX) / epsilon, - (height - heightY) / epsilon, - 1.0 - )); - - vec4 mvPosition = modelViewMatrix * vec4(displaced, 1.0); - - vNormal = normalize(normalMatrix * displacedNormal); - vViewPosition = -mvPosition.xyz; - vWave = height; - - gl_Position = projectionMatrix * mvPosition; -} -`; - -const fragmentShader = ` -uniform vec3 uBaseColor; -uniform vec3 uFresnelColor; -uniform float uFresnelPower; -uniform float uFresnelStrength; -uniform float uHorizonStrength; -uniform float uLateralStrength; -uniform float uIntensity; -uniform float uBaseGlow; -uniform float uOpacity; - -varying vec2 vUv; -varying vec3 vNormal; -varying vec3 vViewPosition; -varying float vWave; - -void main() { - vec3 normal = normalize(vNormal); - vec3 viewDirection = normalize(vViewPosition); - float fresnel = pow(1.0 - max(dot(normal, viewDirection), 0.0), uFresnelPower); - float horizon = smoothstep(0.18, 0.94, vUv.y); - float lateralFresnel = smoothstep(0.18, 1.0, vUv.x); - float blue = clamp( - fresnel * uFresnelStrength - + horizon * uHorizonStrength - + lateralFresnel * uLateralStrength - + vWave * 0.08, - 0.0, - 1.0 - ); - float alpha = smoothstep(0.0, 0.1, vUv.y) * (1.0 - smoothstep(0.8, 1.0, vUv.y)); - float intensity = clamp(uIntensity + fresnel * 0.28 + horizon * 0.12, 0.0, 1.0); - float purpleBalance = clamp(uBaseGlow, 0.0, 1.5); - vec3 color = mix(uBaseColor, uFresnelColor, blue) * intensity; - color = mix(color, uBaseColor * intensity, min(purpleBalance, 1.0)); - color = min(color, max(uBaseColor, uFresnelColor)); - - gl_FragColor = vec4(color, alpha * uOpacity); -} -`; - -function setUniformColor(color: THREE.Color, hex: string) { - const value = Number.parseInt(hex.slice(1), 16); - - color.r = ((value >> 16) & 255) / 255; - color.g = ((value >> 8) & 255) / 255; - color.b = (value & 255) / 255; -} - -function AnimatedPlane({ - inverted, - onReady, - seed, - settings, -}: { - inverted: boolean; - onReady?: () => void; - seed: number; - settings: VoidhashGradientSettings; -}) { - const materialRef = useRef(null); - const didReportReadyRef = useRef(false); - const isMountedRef = useRef(true); - const uniforms = useMemo(() => { - const baseColor = new THREE.Color(); - const fresnelColor = new THREE.Color(); - - setUniformColor(baseColor, settings.baseColor); - setUniformColor(fresnelColor, settings.fresnelColor); - - return { - uBaseColor: { value: baseColor }, - uFresnelColor: { value: fresnelColor }, - uAmplitude: { value: settings.amplitude }, - uBaseGlow: { value: settings.baseGlow }, - uFresnelPower: { value: settings.fresnelPower }, - uFresnelStrength: { value: settings.fresnelStrength }, - uFrequency: { value: settings.frequency }, - uHighFrequency: { value: settings.highFrequency }, - uHorizonStrength: { value: settings.horizonStrength }, - uIntensity: { value: settings.intensity }, - uLateralStrength: { value: settings.lateralStrength }, - uLift: { value: settings.lift }, - uMidFrequency: { value: settings.midFrequency }, - uOpacity: { value: settings.opacity }, - uSeed: { value: seed }, - uTime: { value: 0 }, - }; - }, [seed, settings]); - - useEffect(() => { - return () => { - isMountedRef.current = false; - }; - }, []); - - useEffect(() => { - uniforms.uAmplitude.value = settings.amplitude; - setUniformColor(uniforms.uBaseColor.value, settings.baseColor); - uniforms.uBaseGlow.value = settings.baseGlow; - setUniformColor(uniforms.uFresnelColor.value, settings.fresnelColor); - uniforms.uFresnelPower.value = settings.fresnelPower; - uniforms.uFresnelStrength.value = settings.fresnelStrength; - uniforms.uFrequency.value = settings.frequency; - uniforms.uHighFrequency.value = settings.highFrequency; - uniforms.uHorizonStrength.value = settings.horizonStrength; - uniforms.uIntensity.value = settings.intensity; - uniforms.uLateralStrength.value = settings.lateralStrength; - uniforms.uLift.value = settings.lift; - uniforms.uMidFrequency.value = settings.midFrequency; - uniforms.uOpacity.value = settings.opacity; - uniforms.uSeed.value = seed; - }, [seed, settings, uniforms]); - - useFrame(({ clock }) => { - if (materialRef.current) { - materialRef.current.uniforms.uTime.value = clock.elapsedTime * settings.speed; - } - - if (!didReportReadyRef.current) { - didReportReadyRef.current = true; - requestAnimationFrame(() => { - if (isMountedRef.current) { - onReady?.(); - } - }); - } - }); - - return ( - - - - - - - ); -} - -/** Renders the WebGL plane used by reusable Voidhash gradient backgrounds. */ -export function VoidhashGradientCanvas({ - inverted = false, - onReady, - seed, - settings, -}: { - inverted?: boolean; - onReady?: () => void; - seed: number; - settings: VoidhashGradientSettings; -}) { - return ( - { - gl.outputColorSpace = THREE.SRGBColorSpace; - gl.setClearColor(0x000000, 0); - }} - > - - - ); -} diff --git a/apps/www/src/components/voidhash-gradient-controls.tsx b/apps/www/src/components/voidhash-gradient-controls.tsx deleted file mode 100644 index 2f49f5f0..00000000 --- a/apps/www/src/components/voidhash-gradient-controls.tsx +++ /dev/null @@ -1,175 +0,0 @@ -"use client"; - -import { useState, type Dispatch, type SetStateAction } from "react"; - -import type { VoidhashGradientSettings } from "./voidhash-gradient-settings"; - -type NumericSettingKey = { - [Key in keyof VoidhashGradientSettings]: VoidhashGradientSettings[Key] extends number ? Key : never; -}[keyof VoidhashGradientSettings]; - -type BooleanSettingKey = { - [Key in keyof VoidhashGradientSettings]: VoidhashGradientSettings[Key] extends boolean ? Key : never; -}[keyof VoidhashGradientSettings]; - -type ColorSettingKey = { - [Key in keyof VoidhashGradientSettings]: VoidhashGradientSettings[Key] extends string ? Key : never; -}[keyof VoidhashGradientSettings]; - -const booleanControls: Array<{ key: BooleanSettingKey; label: string }> = [ - { key: "topEnabled", label: "Top effect" }, -]; - -const numericControls: Array<{ - key: NumericSettingKey; - label: string; - min: number; - max: number; - step: number; -}> = [ - { key: "blur", label: "Blur", min: 0, max: 90, step: 1 }, - { key: "effectHeight", label: "Height", min: 70, max: 180, step: 1 }, - { key: "bottomOffset", label: "Bottom", min: -60, max: 20, step: 1 }, - { key: "topOffset", label: "Top", min: -20, max: 60, step: 1 }, - { key: "bottomSeed", label: "Bottom seed", min: 0, max: 80, step: 0.1 }, - { key: "topSeed", label: "Top seed", min: 0, max: 80, step: 0.1 }, - { key: "fadeStart", label: "Fade start", min: 0, max: 80, step: 1 }, - { key: "fadeEnd", label: "Fade full", min: 10, max: 100, step: 1 }, - { key: "planeY", label: "Plane Y", min: -2.4, max: 0.2, step: 0.01 }, - { key: "rotationX", label: "Tilt", min: -1.4, max: -0.25, step: 0.01 }, - { key: "scaleX", label: "Width", min: 0.8, max: 2.2, step: 0.01 }, - { key: "scaleY", label: "Depth", min: 0.5, max: 1.8, step: 0.01 }, - { key: "amplitude", label: "Amplitude", min: 0, max: 1.8, step: 0.01 }, - { key: "frequency", label: "Frequency", min: 0.05, max: 0.5, step: 0.01 }, - { key: "midFrequency", label: "Mid noise", min: 0.8, max: 4, step: 0.01 }, - { key: "highFrequency", label: "Fine noise", min: 1.5, max: 7, step: 0.01 }, - { key: "speed", label: "Speed", min: 0, max: 0.2, step: 0.001 }, - { key: "lift", label: "Lift", min: 0, max: 1, step: 0.01 }, - { key: "fresnelPower", label: "Fresnel pow", min: 0.5, max: 4, step: 0.01 }, - { key: "fresnelStrength", label: "Fresnel", min: 0, max: 3, step: 0.01 }, - { key: "horizonStrength", label: "Horizon", min: 0, max: 2, step: 0.01 }, - { key: "lateralStrength", label: "Blue bias", min: 0, max: 1.5, step: 0.01 }, - { key: "intensity", label: "Intensity", min: 0.2, max: 2, step: 0.01 }, - { key: "baseGlow", label: "Purple mix", min: 0, max: 1.5, step: 0.01 }, - { key: "opacity", label: "Opacity", min: 0, max: 1, step: 0.01 }, -]; - -const colorControls: Array<{ key: ColorSettingKey; label: string }> = [ - { key: "baseColor", label: "Base" }, - { key: "fresnelColor", label: "Fresnel" }, -]; - -function formatValue(value: number) { - if (Math.abs(value) >= 10) { - return value.toFixed(0); - } - - return value.toFixed(2); -} - -/** Renders the debug tuner for Voidhash gradient background settings. */ -export function VoidhashGradientControls({ - onChange, - onHide, - onReset, - settings, - title = "Gradient FX", -}: { - onChange: Dispatch>; - onHide: () => void; - onReset: () => void; - settings: VoidhashGradientSettings; - title?: string; -}) { - const [copied, setCopied] = useState(false); - - return ( -
-
-
{title}
- -
- -
- {booleanControls.map((control) => ( - - ))} - - {colorControls.map((control) => ( - - ))} - - {numericControls.map((control) => ( - - ))} -
- -
- - -
-
- ); -} diff --git a/apps/www/src/components/voidhash-gradient-settings.ts b/apps/www/src/components/voidhash-gradient-settings.ts deleted file mode 100644 index bf826f33..00000000 --- a/apps/www/src/components/voidhash-gradient-settings.ts +++ /dev/null @@ -1,102 +0,0 @@ -export type VoidhashGradientSettings = { - blur: number; - effectHeight: number; - bottomOffset: number; - topEnabled: boolean; - topOffset: number; - bottomSeed: number; - topSeed: number; - fadeStart: number; - fadeEnd: number; - planeY: number; - rotationX: number; - scaleX: number; - scaleY: number; - amplitude: number; - frequency: number; - midFrequency: number; - highFrequency: number; - speed: number; - lift: number; - fresnelPower: number; - fresnelStrength: number; - horizonStrength: number; - lateralStrength: number; - intensity: number; - baseGlow: number; - opacity: number; - baseColor: string; - fresnelColor: string; -}; - -export const VOIDHASH_GRADIENT_STORAGE_KEY = "voidhash:gradient-background-settings"; -export const VOIDHASH_GRADIENT_CONTROLS_STORAGE_KEY = "voidhash:gradient-background-controls"; - -export const DEFAULT_VOIDHASH_GRADIENT_SETTINGS: VoidhashGradientSettings = { - blur: 7, - effectHeight: 98, - bottomOffset: 0, - topEnabled: false, - topOffset: 0, - bottomSeed: 0, - topSeed: 28.7, - fadeStart: 22, - fadeEnd: 58, - planeY: -2.4, - rotationX: -0.86, - scaleX: 1.42, - scaleY: 0.96, - amplitude: 1.42, - frequency: 0.19, - midFrequency: 0.8, - highFrequency: 1.5, - speed: 0.072, - lift: 0.58, - fresnelPower: 0.96, - fresnelStrength: 1.7, - horizonStrength: 0, - lateralStrength: 0.52, - intensity: 1.03, - baseGlow: 0, - opacity: 1, - baseColor: "#7f14ff", - fresnelColor: "#0673ff", -}; - -const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i; - -/** Merges persisted or caller-provided values with the supported gradient settings. */ -export function mergeVoidhashGradientSettings( - value: unknown, - defaults: VoidhashGradientSettings = DEFAULT_VOIDHASH_GRADIENT_SETTINGS, -): VoidhashGradientSettings { - if (!value || typeof value !== "object") { - return defaults; - } - - const settings: VoidhashGradientSettings = { ...defaults }; - const entries = Object.entries(value as Partial>); - - for (const [key, entryValue] of entries) { - if (!(key in settings)) { - continue; - } - - const settingKey = key as keyof VoidhashGradientSettings; - const defaultValue = settings[settingKey]; - - if (typeof defaultValue === "number" && typeof entryValue === "number" && Number.isFinite(entryValue)) { - settings[settingKey] = entryValue as never; - } - - if (typeof defaultValue === "boolean" && typeof entryValue === "boolean") { - settings[settingKey] = entryValue as never; - } - - if (typeof defaultValue === "string" && typeof entryValue === "string" && HEX_COLOR_PATTERN.test(entryValue)) { - settings[settingKey] = entryValue.toLowerCase() as never; - } - } - - return settings; -} diff --git a/apps/www/src/features/auth/components/auth-lenticular-background.tsx b/apps/www/src/features/auth/components/auth-lenticular-background.tsx index 6ace73fe..c92abc7e 100644 --- a/apps/www/src/features/auth/components/auth-lenticular-background.tsx +++ b/apps/www/src/features/auth/components/auth-lenticular-background.tsx @@ -1,6 +1,6 @@ "use client"; -import { HeroShader } from "@/features/www/hero/hero-shader"; +import { HeroShader } from "@/components/hero-shader"; import { cn } from "@/lib/utils"; /** Renders the landing lenticular composition with the animated Perlin surface as its source. */ diff --git a/apps/www/src/features/design/components/docs/colors/index.tsx b/apps/www/src/features/design/components/docs/colors/index.tsx deleted file mode 100644 index 0b50c638..00000000 --- a/apps/www/src/features/design/components/docs/colors/index.tsx +++ /dev/null @@ -1,282 +0,0 @@ -"use client"; - -import { CheckIcon, CopyIcon } from "lucide-react"; -import { useCallback, useState } from "react"; - -import { cn } from "@/features/design/lib/cn"; - -import { - BRAND_SCALES, - isLightColor, - resolveToken, - scaleSteps, - SEMANTIC_GROUPS, - type SemanticToken, - type ThemeName, -} from "./tokens"; - -const THEMES: ThemeName[] = ["light", "dark"]; - -const useCopyValue = () => { - const [copied, setCopied] = useState(undefined); - - const copy = useCallback((value: string) => { - void navigator.clipboard.writeText(value).then(() => { - setCopied(value); - setTimeout(() => setCopied((current) => (current === value ? undefined : current)), 1200); - }); - }, []); - - return { copied, copy }; -}; - -interface CopyButtonProps { - className?: string; - copied: boolean; - label: string; - onCopy: () => void; - value: string; -} - -function CopyButton({ className, copied, label, onCopy, value }: CopyButtonProps) { - return ( - - ); -} - -interface TokenSwatchProps { - theme: ThemeName; - token: SemanticToken; -} - -function TokenSwatch({ theme, token }: TokenSwatchProps) { - const resolved = resolveToken(token.name, theme); - if (!resolved) { - return null; - } - - const on = token.on ? resolveToken(token.on, theme) : undefined; - const fallbackText = isLightColor(resolved.value) ? "oklch(0% 0 0)" : "oklch(100% 0 0)"; - - return ( -
- - {token.on ? "Aa" : theme === "light" ? "L" : "D"} - -
- ); -} - -interface TokenValueProps { - copiedValue: string | undefined; - onCopy: (value: string) => void; - theme: ThemeName; - token: SemanticToken; -} - -function TokenValue({ copiedValue, onCopy, theme, token }: TokenValueProps) { - const resolved = resolveToken(token.name, theme); - if (!resolved) { - return null; - } - - return ( -
- {theme} - onCopy(resolved.value)} - value={resolved.value} - /> - {resolved.alias ? via {resolved.alias} : null} -
- ); -} - -interface SemanticTokenCardProps { - copiedValue: string | undefined; - onCopy: (value: string) => void; - token: SemanticToken; -} - -function SemanticTokenCard({ copiedValue, onCopy, token }: SemanticTokenCardProps) { - const variable = `var(--${token.name})`; - - return ( -
-
- {THEMES.map((theme) => ( - - ))} -
- -
-
- onCopy(variable)} - value={`--${token.name}`} - /> - {token.on ? ( - - on --{token.on} - - ) : null} -
- -

{token.meaning}

- -
- {token.utilities.map((utility) => ( - - {utility} - - ))} -
- -
- {THEMES.map((theme) => ( - - ))} -
-
-
- ); -} - -/** - * Renders every semantic theme token grouped by intent, with its light and dark - * value, the alias it resolves through, and the Tailwind utilities that map to - * it. Values are read from `@voidhash/ui/styles/brand-theme.css`, so this page - * cannot drift from the theme. - */ -export function SemanticColorTokens() { - const { copied, copy } = useCopyValue(); - - return ( -
- {SEMANTIC_GROUPS.map((group) => ( -
-
-

{group.title}

-

{group.description}

-
- -
- {group.tokens.map((token) => ( - - ))} -
-
- ))} -
- ); -} - -/** - * Renders the raw brand ramps every semantic token is built from. Scales are - * theme-independent — only the semantic tokens above remap between light and - * dark. - */ -export function BrandColorScales() { - const { copied, copy } = useCopyValue(); - - return ( -
- {BRAND_SCALES.map((scale) => { - const steps = scaleSteps(scale.prefix); - - return ( -
-
-

{scale.title}

-

{scale.meaning}

-
- -
- {steps.map((step) => { - const name = `${scale.prefix}-${step}`; - const resolved = resolveToken(name, "light"); - if (!resolved) { - return null; - } - - const variable = `var(--${name})`; - - return ( - - ); - })} -
-
- ); - })} -
- ); -} diff --git a/apps/www/src/features/design/components/docs/colors/tokens.ts b/apps/www/src/features/design/components/docs/colors/tokens.ts deleted file mode 100644 index 9f912470..00000000 --- a/apps/www/src/features/design/components/docs/colors/tokens.ts +++ /dev/null @@ -1,452 +0,0 @@ -import brandThemeCss from "@voidhash/ui/styles/brand-theme.css?raw"; - -export type ThemeName = "light" | "dark"; - -export interface ResolvedToken { - /** Final color value with every `var()` indirection followed. */ - value: string; - /** The variable this token points at, when it is defined as an alias. */ - alias?: string; -} - -export interface SemanticToken { - /** Custom property name without the leading dashes, e.g. `primary`. */ - name: string; - /** What the token is for and when to reach for it. */ - meaning: string; - /** Tailwind utilities that map onto this token. */ - utilities: string[]; - /** Token used for text/icons placed on top of this one, if there is a pair. */ - on?: string; -} - -export interface SemanticGroup { - title: string; - description: string; - tokens: SemanticToken[]; -} - -export interface BrandScale { - /** Custom property prefix, e.g. `blue-ribbon` for `--blue-ribbon-500`. */ - prefix: string; - title: string; - meaning: string; -} - -const SELECTOR_LIGHT = ":root"; -const SELECTOR_DARK = ".dark"; -const ALIAS_PATTERN = /^var\((--[\w-]+)\)$/; - -/** - * Reads the custom properties declared in a single flat rule block. The brand - * theme keeps `:root` and `.dark` free of nested rules, so a brace scan is - * enough — no CSS parser needed. - */ -const readDeclarations = (css: string, selector: string): Record => { - const selectorStart = css.indexOf(`${selector} {`); - if (selectorStart === -1) { - return {}; - } - - const blockStart = css.indexOf("{", selectorStart); - const blockEnd = css.indexOf("}", blockStart); - const declarations: Record = {}; - - for (const declaration of css.slice(blockStart + 1, blockEnd).split(";")) { - const separator = declaration.indexOf(":"); - if (separator === -1) { - continue; - } - - const name = declaration.slice(0, separator).trim(); - if (name.startsWith("--")) { - declarations[name] = declaration.slice(separator + 1).trim(); - } - } - - return declarations; -}; - -const LIGHT_DECLARATIONS = readDeclarations(brandThemeCss, SELECTOR_LIGHT); -const DARK_DECLARATIONS = { - ...LIGHT_DECLARATIONS, - ...readDeclarations(brandThemeCss, SELECTOR_DARK), -}; - -const declarationsFor = (theme: ThemeName) => - theme === "dark" ? DARK_DECLARATIONS : LIGHT_DECLARATIONS; - -const follow = (declarations: Record, value: string, depth = 0): string => { - const alias = ALIAS_PATTERN.exec(value); - const target = alias ? declarations[alias[1]] : undefined; - if (!target || depth > 10) { - return value; - } - - return follow(declarations, target, depth + 1); -}; - -/** - * Resolves a theme token to its literal color, following alias chains such as - * `--primary` → `--blue-ribbon-600` → `oklch(…)`. Returns `undefined` when the - * token is not declared for the given theme. - */ -export const resolveToken = (name: string, theme: ThemeName): ResolvedToken | undefined => { - const declarations = declarationsFor(theme); - const raw = declarations[`--${name}`]; - if (!raw) { - return undefined; - } - - const alias = ALIAS_PATTERN.exec(raw); - return { - alias: alias?.[1], - value: follow(declarations, raw), - }; -}; - -/** Lists the steps declared for a scale prefix, ordered light to dark. */ -export const scaleSteps = (prefix: string): number[] => - Object.keys(LIGHT_DECLARATIONS) - .map((name) => { - const match = new RegExp(`^--${prefix}-(\\d+)$`).exec(name); - return match ? Number(match[1]) : undefined; - }) - .filter((step): step is number => step !== undefined) - .sort((a, b) => a - b); - -/** - * Estimates whether a color is light enough to need dark text on top. Handles - * the two literal formats used by the theme: `oklch(L% C H)` and hex. - */ -export const isLightColor = (value: string): boolean => { - const oklch = /^oklch\(\s*([\d.]+)%/.exec(value); - if (oklch) { - return Number(oklch[1]) >= 62; - } - - const hex = /^#([\da-f]{6})$/i.exec(value); - if (hex) { - const int = Number.parseInt(hex[1], 16); - const luminance = - (0.2126 * ((int >> 16) & 0xff) + 0.7152 * ((int >> 8) & 0xff) + 0.0722 * (int & 0xff)) / 255; - return luminance >= 0.55; - } - - return true; -}; - -export const SEMANTIC_GROUPS: SemanticGroup[] = [ - { - description: - "The stack of neutral surfaces, from the page canvas up to floating layers. Pick the one that matches how far the element is lifted off the page, not the color you want.", - title: "Surfaces", - tokens: [ - { - meaning: - "The app canvas. Everything else sits on top of it. Set once on `body` — components should not repaint it.", - name: "background", - on: "foreground", - utilities: ["bg-background"], - }, - { - meaning: - "A neutral surface raised off the canvas: toolbars, inspector rails, list rows that need separation without a card border.", - name: "surface", - on: "foreground", - utilities: ["bg-surface"], - }, - { - meaning: - "A recessed surface for wells and tracks — slider rails, progress backgrounds, inset code blocks.", - name: "surface-muted", - on: "foreground", - utilities: ["bg-surface-muted"], - }, - { - meaning: - "Content containers. Use with `--border` for the outline; in dark mode it reads lighter than the canvas so cards float.", - name: "card", - on: "card-foreground", - utilities: ["bg-card"], - }, - { - meaning: "Text and icons inside a card.", - name: "card-foreground", - utilities: ["text-card-foreground"], - }, - { - meaning: - "App chrome around the workspace — designer and editor panels. Slightly darker than `--card` in dark mode so tooling recedes behind content.", - name: "panel", - on: "foreground", - utilities: ["bg-panel"], - }, - { - meaning: - "Layers that float above the page: dropdowns, menus, tooltips, comboboxes, date pickers.", - name: "popover", - on: "popover-foreground", - utilities: ["bg-popover"], - }, - { - meaning: "Text and icons inside a popover layer.", - name: "popover-foreground", - utilities: ["text-popover-foreground"], - }, - ], - }, - { - description: - "Text and icon colors. Body copy is `--foreground`; anything quieter steps down to `--muted-foreground` rather than lowering opacity.", - title: "Content", - tokens: [ - { - meaning: "Default body text, headings, and icons on the canvas.", - name: "foreground", - utilities: ["text-foreground"], - }, - { - meaning: - "Secondary text: labels, helper copy, placeholders, timestamps, inactive icons. The lowest-emphasis text that still meets contrast.", - name: "muted-foreground", - utilities: ["text-muted-foreground"], - }, - { - meaning: - "Quiet neutral fill for badges, skeletons, disabled controls, and hovered table rows.", - name: "muted", - on: "muted-foreground", - utilities: ["bg-muted"], - }, - ], - }, - { - description: - "Interactive intent. One primary action per view; everything competing with it drops to secondary or ghost styling.", - title: "Actions", - tokens: [ - { - meaning: - "The primary action and brand accent — solid buttons, selected states, links, active nav items.", - name: "primary", - on: "primary-foreground", - utilities: ["bg-primary", "text-primary", "border-primary"], - }, - { - meaning: "Text and icons on a primary fill. Stays white in both themes.", - name: "primary-foreground", - utilities: ["text-primary-foreground"], - }, - { - meaning: "Neutral, lower-emphasis actions that sit next to a primary button.", - name: "secondary", - on: "secondary-foreground", - utilities: ["bg-secondary"], - }, - { - meaning: "Text and icons on a secondary fill.", - name: "secondary-foreground", - utilities: ["text-secondary-foreground"], - }, - { - meaning: - "Hover and highlight state for list-like surfaces: menu items, command results, sidebar rows, ghost buttons.", - name: "accent", - on: "accent-foreground", - utilities: ["bg-accent", "hover:bg-accent"], - }, - { - meaning: "Text and icons on an accent highlight.", - name: "accent-foreground", - utilities: ["text-accent-foreground"], - }, - ], - }, - { - description: - "Status colors. Reserved for outcomes and risk — never used decoratively, so their appearance always carries meaning.", - title: "Feedback", - tokens: [ - { - meaning: - "Destructive and irreversible actions, error states, invalid fields. Pair with a confirmation for anything unrecoverable.", - name: "destructive", - on: "destructive-foreground", - utilities: ["bg-destructive", "text-destructive", "border-destructive"], - }, - { - meaning: "Text and icons on a destructive fill.", - name: "destructive-foreground", - utilities: ["text-destructive-foreground"], - }, - { - meaning: "Successful outcomes and healthy status — completed steps, live deployments.", - name: "success", - on: "success-foreground", - utilities: ["bg-success", "text-success"], - }, - { - meaning: "Text and icons on a success fill.", - name: "success-foreground", - utilities: ["text-success-foreground"], - }, - ], - }, - { - description: - "Hairlines, control outlines, and focus. These are the only tokens allowed to draw structure — do not fake borders with a background color.", - title: "Borders and focus", - tokens: [ - { - meaning: - "Default hairline between surfaces. Applied globally by the base layer, so most elements inherit it without a border utility.", - name: "border", - utilities: ["border-border"], - }, - { - meaning: "Outline of form controls — inputs, textareas, selects, checkboxes.", - name: "input", - utilities: ["border-input"], - }, - { - meaning: - "Keyboard focus ring. The base layer renders it at 50% opacity (`outline-ring/50`), so focus reads clearly without shouting.", - name: "ring", - utilities: ["ring-ring", "outline-ring/50"], - }, - ], - }, - { - description: - "The sidebar runs its own surface stack so navigation can go darker than the app without dragging the rest of the UI with it.", - title: "Sidebar", - tokens: [ - { - meaning: "Sidebar background. Pure black in dark mode, pinning navigation to the far edge.", - name: "sidebar", - on: "sidebar-foreground", - utilities: ["bg-sidebar"], - }, - { - meaning: "Sidebar labels and icons.", - name: "sidebar-foreground", - utilities: ["text-sidebar-foreground"], - }, - { - meaning: - "Active navigation item. Deliberately neutral rather than brand blue so the sidebar does not compete with in-page primary actions.", - name: "sidebar-primary", - on: "sidebar-primary-foreground", - utilities: ["bg-sidebar-primary"], - }, - { - meaning: "Text on an active navigation item.", - name: "sidebar-primary-foreground", - utilities: ["text-sidebar-primary-foreground"], - }, - { - meaning: "Hovered navigation item.", - name: "sidebar-accent", - on: "sidebar-accent-foreground", - utilities: ["bg-sidebar-accent"], - }, - { - meaning: "Text on a hovered navigation item.", - name: "sidebar-accent-foreground", - utilities: ["text-sidebar-accent-foreground"], - }, - { - meaning: "Dividers inside the sidebar and the seam against the app canvas.", - name: "sidebar-border", - utilities: ["border-sidebar-border"], - }, - { - meaning: "Focus ring for sidebar controls.", - name: "sidebar-ring", - utilities: ["ring-sidebar-ring"], - }, - ], - }, - { - description: - "Categorical series colors, ordered by how they should be assigned. Use them in sequence so the same series index keeps the same color across charts.", - title: "Data visualization", - tokens: [ - { - meaning: "First series — the metric the chart is about.", - name: "chart-1", - utilities: ["fill-chart-1", "stroke-chart-1"], - }, - { - meaning: "Second series.", - name: "chart-2", - utilities: ["fill-chart-2", "stroke-chart-2"], - }, - { - meaning: "Third series.", - name: "chart-3", - utilities: ["fill-chart-3", "stroke-chart-3"], - }, - { - meaning: "Fourth series.", - name: "chart-4", - utilities: ["fill-chart-4", "stroke-chart-4"], - }, - { - meaning: "Fifth series. Beyond five categories, group the tail into an “Other” bucket.", - name: "chart-5", - utilities: ["fill-chart-5", "stroke-chart-5"], - }, - ], - }, -]; - -export const BRAND_SCALES: BrandScale[] = [ - { - meaning: - "The Voidhash brand hue. Step 600 is `--primary`, step 500 is `--ring`. Lighter steps back tinted surfaces; darker steps are for text on tinted backgrounds.", - prefix: "blue-ribbon", - title: "Blue Ribbon", - }, - { - meaning: - "Secondary brand hue. Used for the second chart series and for AI/agent surfaces that need to read as distinct from primary actions.", - prefix: "electric-violet", - title: "Electric Violet", - }, - { - meaning: "Third chart series and accent illustrations. Not used for interactive states.", - prefix: "fuchsia-pink", - title: "Fuchsia Pink", - }, - { - meaning: - "Danger. Step 600 is `--destructive` in light mode, step 500 in dark mode where the surface is darker.", - prefix: "radical-red", - title: "Radical Red", - }, - { - meaning: - "Reserved for high-urgency, non-destructive states. No semantic token maps to it yet, so reference the scale directly and document the usage.", - prefix: "blaze-orange", - title: "Blaze Orange", - }, - { - meaning: - "Warnings and pending states — there is no `--warning` token, so use `amber-500`/`amber-600` when you need caution without danger. Also the fourth chart series.", - prefix: "amber", - title: "Amber", - }, - { - meaning: "Success and healthy status. Step 600 is `--success`; step 500 is the fifth series.", - prefix: "pistachio", - title: "Pistachio", - }, - { - meaning: - "The neutral ramp every surface, border, and text token is built from. Light mode maps 50–200 to surfaces and 500–900 to text; dark mode inverts that.", - prefix: "zinc", - title: "Zinc", - }, -]; diff --git a/apps/www/src/features/design/components/docs/component-overview.tsx b/apps/www/src/features/design/components/docs/component-overview.tsx deleted file mode 100644 index f2488d0c..00000000 --- a/apps/www/src/features/design/components/docs/component-overview.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; - -import Preview02Example from "./component-overview/index"; - -/** - * Renders shadcn's preview-02 composition using the Voidhash UI primitives. - */ -export function ComponentOverview() { - return ( -
- -
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/account-access.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/account-access.tsx deleted file mode 100644 index 24981f39..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/account-access.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { Button } from "@voidhash/ui"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { Field, FieldGroup, FieldLabel } from "@voidhash/ui"; -import { Input } from "@voidhash/ui"; -import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function AccountAccess() { - return ( - - - Account Access - Update your credentials or re-authenticate. - - - - - Email Address - - - -
- - - - - - - - - - - - - Danger Zone - - Archive account and remove catalog - - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/card-overview.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/card-overview.tsx deleted file mode 100644 index 74c81fe0..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/card-overview.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client"; - -import { Bar, BarChart, XAxis } from "recharts"; - -import { Badge } from "@voidhash/ui"; -import { Button } from "@voidhash/ui"; -import { Card, CardContent, CardDescription, CardTitle } from "@voidhash/ui"; -import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@voidhash/ui"; - -const activityData = [ - { month: "Jan", amount: 40 }, - { month: "Feb", amount: 55 }, - { month: "Mar", amount: 35 }, - { month: "Apr", amount: 60 }, - { month: "May", amount: 45 }, - { month: "Jun", amount: 50 }, - { month: "Jul", amount: 65 }, - { month: "Aug", amount: 40 }, - { month: "Sep", amount: 55 }, - { month: "Oct", amount: 70 }, - { month: "Nov", amount: 45 }, - { month: "Dec", amount: 80 }, -]; - -const chartConfig = { - amount: { - label: "Activity", - color: "var(--chart-2)", - }, -} satisfies ChartConfig; - -export function CardOverview() { - return ( -
- - - Card Balance - US$12.94 - US$11,337.06 Available - - - - -
- Payment Due - 1 Apr -
- -
-
- - -
- Yearly Activity - +US$0.25 Daily Cash -
- - - String(v).slice(0, 1)} - className="text-[10px]" - /> - } /> - - - -
-
-
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/claimable-balance.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/claimable-balance.tsx deleted file mode 100644 index 1c7e3f98..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/claimable-balance.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Badge } from "@voidhash/ui"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { Item, ItemContent } from "@voidhash/ui"; -import { Separator } from "@voidhash/ui"; - -export function ClaimableBalance() { - return ( - - - Claimable Balance - $0.00 - - - Pending Setup - - - - - -
- Net Royalties - $0.00 -
-
- Processing Fee - -$0.00 -
- -
- Total Ready to Claim - $0.00 USD -
-
-
-
- - - Once your bank is connected, balances over $10.00 are automatically eligible for monthly - distribution on the 15th of each month. - - -
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/contribution-history.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/contribution-history.tsx deleted file mode 100644 index 120fb2ec..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/contribution-history.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { Bar, BarChart, XAxis } from "recharts"; - -import { Button } from "@voidhash/ui"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@voidhash/ui"; -import { Item, ItemContent, ItemDescription } from "@voidhash/ui"; -import { useDesignSystemSearchParams } from "../preview-config"; - -const chartData = [ - { month: "Dec", amount: 800 }, - { month: "Jan", amount: 1100 }, - { month: "Feb", amount: 900 }, - { month: "Mar", amount: 1300 }, - { month: "Apr", amount: 750 }, - { month: "May", amount: 1400 }, -]; - -const chartConfig = { - amount: { - label: "Contribution", - color: "var(--chart-2)", - }, -} satisfies ChartConfig; - -export function ContributionHistory() { - const [params] = useDesignSystemSearchParams(); - const isRounded = !["lyra", "sera"].includes(params.style); - - return ( - - - Contribution History - Last 6 months of activity - - - - - - } - /> - - - - - -
- - - - Upcoming - - May 25, 2024 - $1,000 scheduled - - - - - - Auto-Save Plan - - Accelerated - Recurring weekly - - -
-
- - - -
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/cover-art.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/cover-art.tsx deleted file mode 100644 index 383abf71..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/cover-art.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Button } from "@voidhash/ui"; -import { Card, CardContent, CardDescription, CardFooter } from "@voidhash/ui"; -import { Item } from "@voidhash/ui"; -import { Label } from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function CoverArt() { - return ( - - - - - - - - - - - - Minimum 3000 × 3000px -
- JPEG or PNG only -
-
-
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/dividend-income.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/dividend-income.tsx deleted file mode 100644 index d1c67686..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/dividend-income.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { Bar, BarChart } from "recharts"; - -import { Button } from "@voidhash/ui"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@voidhash/ui"; -import { Item, ItemContent, ItemDescription, ItemGroup, ItemTitle } from "@voidhash/ui"; -import { useDesignSystemSearchParams } from "../preview-config"; -import { IconPlaceholder } from "../icon-placeholder"; - -const HOLDINGS = [ - { - name: "Vanguard VIG", - shares: "450 Shares", - amount: "$1,842.10", - data: [ - { q: "Q1", value: 380 }, - { q: "Q2", value: 420 }, - { q: "Q3", value: 390 }, - { q: "Q4", value: 652 }, - ], - }, - { - name: "S&P 500 VOO", - shares: "112 Shares", - amount: "$928.40", - data: [ - { q: "Q1", value: 180 }, - { q: "Q2", value: 210 }, - { q: "Q3", value: 320 }, - { q: "Q4", value: 218 }, - ], - }, - { - name: "Apple AAPL", - shares: "85 Shares", - amount: "$340.00", - data: [ - { q: "Q1", value: 60 }, - { q: "Q2", value: 70 }, - { q: "Q3", value: 120 }, - { q: "Q4", value: 90 }, - ], - }, - { - name: "Realty Income", - shares: "320 Shares", - amount: "$1,139.50", - data: [ - { q: "Q1", value: 240 }, - { q: "Q2", value: 260 }, - { q: "Q3", value: 280 }, - { q: "Q4", value: 360 }, - ], - }, -]; - -const miniChartConfig = { - value: { - label: "Dividend", - color: "var(--chart-2)", - }, -} satisfies ChartConfig; - -export function DividendIncome() { - const [params] = useDesignSystemSearchParams(); - const isRounded = !["lyra", "sera"].includes(params.style); - - return ( - - - Q2 Dividend Income - - Quarterly dividend payouts across your portfolio holdings. - - - - - - - - {HOLDINGS.map((holding) => ( - - - {holding.name} - {holding.shares} - - - - } /> - - - - - {holding.amount} - - - ))} - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/empty-connect-bank.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/empty-connect-bank.tsx deleted file mode 100644 index 9f37a47e..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/empty-connect-bank.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Button } from "@voidhash/ui"; -import { Card, CardContent } from "@voidhash/ui"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function EmptyConnectBank() { - return ( - - - - - - - - Connect Bank - - Link your payout method to receive monthly royalty distributions automatically. - - - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/empty-distribute-track.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/empty-distribute-track.tsx deleted file mode 100644 index 7f79c0ed..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/empty-distribute-track.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Button } from "@voidhash/ui"; -import { Card, CardContent } from "@voidhash/ui"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function EmptyDistributeTrack() { - return ( - - - - - - - - Distribute Track - - Upload your first master to start reaching listeners on Spotify, Apple Music, and - more. - - - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/empty-explore-catalog.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/empty-explore-catalog.tsx deleted file mode 100644 index 7411baae..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/empty-explore-catalog.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Button } from "@voidhash/ui"; -import { Card, CardContent } from "@voidhash/ui"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function EmptyExploreCatalog() { - return ( - - - - - - - - Explore Catalog - - Check your ISRC codes, metadata, and visual assets before going live. - - - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx deleted file mode 100644 index b895b603..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@voidhash/ui"; -import { Button } from "@voidhash/ui"; -import { Card, CardContent, CardFooter } from "@voidhash/ui"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@voidhash/ui"; - -const GENERAL_QUESTIONS = [ - { - q: "How secure is my financial data with Ledger?", - a: "We use bank-level AES-256 encryption, SOC 2 Type II certified infrastructure, and never store your credentials. All connections use read-only access tokens. We are a SEC registered investment advisor.", - }, - { - q: "How do I connect my bank or investment accounts?", - a: "Go to Settings > Linked Accounts and search for your institution. We support over 12,000 banks and brokerages via Plaid and MX.", - }, - { - q: "Can I export my data for tax purposes?", - a: "Yes. Navigate to Reports > Tax Export to download a CSV or PDF summary of your transactions, dividends, and capital gains for any tax year.", - }, -]; - -const SYNC_QUESTIONS = [ - { - q: "How often does data refresh?", - a: "Connected institutions sync every six hours, and you can pull a manual refresh from the account detail view at any time.", - }, - { - q: "Why is a transaction missing?", - a: "Pending transactions appear once the institution posts them. If a posted transaction is still missing after 48 hours, reconnect the account from Settings.", - }, - { - q: "Can I import a statement manually?", - a: "Yes. Upload a CSV or OFX file from the account detail view and map the columns once — the mapping is remembered for later imports.", - }, -]; - -const GOALS_QUESTIONS = [ - { - q: "How do I set up a custom financial goal?", - a: "Click New Goal from the Savings Targets card. Choose a category, set a target amount and date, and we'll calculate the monthly contribution needed.", - }, - { - q: "Can I track multiple goals at once?", - a: "Yes. Pro accounts can track unlimited goals. Basic accounts support up to 3 active goals.", - }, - { - q: "How are monthly contributions calculated?", - a: "We divide the remaining amount by the number of months until your target date, adjusted for your current savings rate and any auto-transfer schedules.", - }, -]; - -function QuestionList({ questions }: { questions: { q: string; a: string }[] }) { - return ( - - {questions.map((item, index) => ( - - {item.q} - {item.a} - - ))} - - ); -} - -export function Faq() { - return ( - - - - - - General - - - Sync - - - Goals - - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/front-door.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/front-door.tsx deleted file mode 100644 index 240d3a14..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/front-door.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Badge } from "@voidhash/ui"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function FrontDoor() { - return ( - - - Front Door - Smart Lock Pro - -
- Locked - -
-
-
- -
- - Live - -
-
-
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/index-investing.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/index-investing.tsx deleted file mode 100644 index d5d5b2cc..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/index-investing.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@voidhash/ui"; - -export function IndexInvesting() { - return ( - - - Dollar-Cost Averaging - A strategy for building wealth over time. - - - - - Over time - - , this smooths out the average cost of your investments. When prices drop, your fixed - amount buys more shares. When prices rise, you buy fewer. The result is a lower average - cost per share compared to lump-sum investing during volatile periods. - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/kitchen-island.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/kitchen-island.tsx deleted file mode 100644 index db97fc69..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/kitchen-island.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { Item, ItemActions, ItemContent, ItemGroup, ItemMedia, ItemTitle } from "@voidhash/ui"; -import { Slider } from "@voidhash/ui"; -import { Switch } from "@voidhash/ui"; -import { ToggleGroup, ToggleGroupItem } from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -const SCENES = { - cooking: { brightness: [90], colorTemp: [70], volume: [30], fade: [0] }, - dining: { brightness: [50], colorTemp: [40], volume: [20], fade: [60] }, - nightlight: { brightness: [15], colorTemp: [20], volume: [0], fade: [80] }, - focus: { brightness: [100], colorTemp: [85], volume: [0], fade: [0] }, -} as const; - -export function KitchenIsland() { - const [enabled, setEnabled] = React.useState(true); - const [scene, setScene] = React.useState("cooking"); - const [brightness, setBrightness] = React.useState([90]); - const [colorTemp, setColorTemp] = React.useState([70]); - const [volume, setVolume] = React.useState([30]); - const [fade, setFade] = React.useState([0]); - - const handleSceneChange = (value: string) => { - if (!value) return; - setScene(value); - const preset = SCENES[value as keyof typeof SCENES]; - setBrightness([...preset.brightness]); - setColorTemp([...preset.colorTemp]); - setVolume([...preset.volume]); - setFade([...preset.fade]); - }; - - return ( - - - Kitchen Island - Hue Color Ambient - - - - - -
- Scenes - - - Cooking - - - Dining - - - Nightlight - - - Focus - - -
- - - - - - - Brightness - - - - - - - - - - - Color Temp - - - - - - - - - - - Volume - - - - - - - - - - - Fade - - - - - - -
-
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/loading-card.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/loading-card.tsx deleted file mode 100644 index 38dc5715..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/loading-card.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Card, CardContent, CardHeader } from "@voidhash/ui"; -import { Skeleton } from "@voidhash/ui"; - -export function LoadingCard() { - return ( - - - - - - - -
- - - -
-
- - -
-
-
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/new-milestone.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/new-milestone.tsx deleted file mode 100644 index 0bc6c3d2..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/new-milestone.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { Button } from "@voidhash/ui"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { Field, FieldGroup, FieldLabel } from "@voidhash/ui"; -import { Input } from "@voidhash/ui"; - -export function NewMilestone() { - return ( - - - Set a new milestone - - Define your financial target and we'll help you pace your savings. - - - - - - Goal Name - - -
- - Target Amount - - - - Target Date - - -
-
-
- - - - -
- ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/notification-settings.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/notification-settings.tsx deleted file mode 100644 index 3074bebb..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/notification-settings.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { Button } from "@voidhash/ui"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { Checkbox } from "@voidhash/ui"; -import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel } from "@voidhash/ui"; - -const NOTIFICATIONS = [ - { - id: "transactions", - label: "Transaction alerts", - description: "Deposits, withdrawals, and transfers.", - defaultChecked: true, - }, - { - id: "security", - label: "Security alerts", - description: "Login attempts and account changes.", - defaultChecked: true, - }, - { - id: "goals", - label: "Goal milestones", - description: "Updates at 25%, 50%, 75%, and 100%.", - defaultChecked: false, - }, - { - id: "market", - label: "Market updates", - description: "Daily portfolio summary and price alerts.", - defaultChecked: false, - }, -]; - -export function NotificationSettings() { - const [checked, setChecked] = React.useState>( - Object.fromEntries(NOTIFICATIONS.map((n) => [n.id, n.defaultChecked])), - ); - - const allChecked = NOTIFICATIONS.every((n) => checked[n.id]); - const someChecked = NOTIFICATIONS.some((n) => checked[n.id]) && !allChecked; - - const handleSelectAll = (value: boolean) => { - setChecked(Object.fromEntries(NOTIFICATIONS.map((n) => [n.id, value]))); - }; - - const handleToggle = (id: string, value: boolean) => { - setChecked((prev) => ({ ...prev, [id]: value })); - }; - - return ( - - - Notifications - Choose what you want to be notified about. - - - - - handleSelectAll(!!v)} - /> - - Select all - - - {NOTIFICATIONS.map((n) => ( - - handleToggle(n.id, !!v)} - /> - - {n.label} - {n.description} - - - ))} - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/payments.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/payments.tsx deleted file mode 100644 index 0e7be017..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/payments.tsx +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@voidhash/ui"; -import { Button } from "@voidhash/ui"; -import { Card, CardContent, CardHeader } from "@voidhash/ui"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@voidhash/ui"; -import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function Payments() { - return ( - - - - - - Home - - - - - - - - - - Profile - Statements - Documents - - - - - - - Payments - - - - - - - - - - - - - Change transfer limit - Adjust how much you can send from your balance. - - - - - - - - - - - Scheduled transfers - Set up a transfer to send at a later date. - - - - - - - - - - - Direct Debits - Set up and manage regular payments. - - - - - - - - - - - Recurring card payments - Manage your repeated card transactions. - - - - - - - - ); -} diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/payout-threshold.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/payout-threshold.tsx deleted file mode 100644 index a8a40273..00000000 --- a/apps/www/src/features/design/components/docs/component-overview/cards/payout-threshold.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { Button } from "@voidhash/ui"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@voidhash/ui"; -import { Field, FieldDescription, FieldGroup, FieldLabel } from "@voidhash/ui"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@voidhash/ui"; -import { Slider } from "@voidhash/ui"; -import { Textarea } from "@voidhash/ui"; -import { IconPlaceholder } from "../icon-placeholder"; - -export function PayoutThreshold() { - const [amount, setAmount] = React.useState([2500]); - - return ( - - - Payout Threshold - - Set the minimum balance required before a payout is triggered. - - - - - - - - - Preferred Currency - - - -
- Minimum Payout Amount - ${amount[0].toFixed(2)} -
- -
- $50 (MIN) - $10,000 (MAX) -
-
- - Notes -