diff --git a/.agent/skills/effect-context-manager/SKILL.md b/.agent/skills/effect-context-manager/SKILL.md index d5c34a2e..d417e151 100644 --- a/.agent/skills/effect-context-manager/SKILL.md +++ b/.agent/skills/effect-context-manager/SKILL.md @@ -1,8 +1,8 @@ --- name: effect-context-manager description: > - Gestiona el entorno de referencia de Effect v4 alojado en el worktree ./effect-reference. - Trigger: Cuando se necesita consultar código de effect-smol, actualizar el contexto, o configurar en nueva máquina. + Manages the local Effect v4 and Alchemy reference clones hosted in ./.effect-reference. + Trigger: When Effect or Alchemy code needs to be consulted, the context needs to be updated, or a new machine needs to be set up. license: Apache-2.0 metadata: author: gentleman-programming @@ -11,90 +11,116 @@ metadata: ## When to Use -- Clonar el proyecto en una nueva máquina y montar el worktree de effect-reference -- Actualizar el contexto de Effect desde el origen remoto (effect-smol) -- Consultar patrones de Effect-TS en el código de referencia -- Verificar que el worktree está sincronizado correctamente +- Clone the local Effect v4 and Alchemy references on a new machine +- Update Effect from the `main` branch of `Effect-TS/effect` +- Update Alchemy from the `main` branch of `alchemy-run/alchemy` +- Consult current patterns directly in the reference clones +- Verify that both depth-1 clones are synchronized with their upstreams -## Critical Patterns - -### Protocolo 1: Setup en Nueva Máquina (Clone & Mount) +## Canonical Sources -Cuando el directorio `./effect-reference` no existe o el usuario menciona "nueva máquina": - -```bash -# 1. Obtener la rama huérfana del remoto -git fetch origin effect-context +- `.effect-reference/effect` is a depth-1 clone of the `main` branch of `https://github.com/Effect-TS/effect.git` and is the local Effect v4 reference. +- Effect v3 corresponds to the `v3` branch of the same `Effect-TS/effect` repository; it must not be used as the target for the v4 reference. +- `.effect-reference/alchemy` is a depth-1 clone of the `main` branch of `https://github.com/alchemy-run/alchemy.git` and is the canonical Effect-based Alchemy next/alpha reference. +- `alchemy-run/alchemy-async` is the former async implementation, not the current canonical reference. -# 2. Montar el worktree -git worktree add .effect-reference origin/effect-context +## Critical Patterns -# 3. Confirmar lectura del archivo de migración -cat .effect-reference/MIGRATION.md -``` +### Protocol 1: Setup on a New Machine -### Protocolo 2: Actualización desde el Origen (Sync) +The reference directories are ignored by Git and are independent clones. They are neither worktrees of the main repository nor orphan branches. -Cuando el usuario pida "actualizar el contexto de Effect" o "traer lo último de effect-smol": +When one of the directories does not exist or the user mentions a new machine: ```bash -# 1. Entrar al directorio del worktree -cd .effect-reference - -# 2. Descargar archivos más recientes (shallow) -git fetch https://github.com/Effect-TS/effect-smol.git main --depth 1 - -# 3. Sobrescribir archivos locales sin mezclar historiales -git checkout FETCH_HEAD -- . +# 1. Create the ignored container directory +mkdir -p .effect-reference + +# 2. Clone only the canonical branches with depth 1 +git clone --depth 1 --branch main https://github.com/Effect-TS/effect.git .effect-reference/effect +git clone --depth 1 --branch main https://github.com/alchemy-run/alchemy.git .effect-reference/alchemy + +# 3. Confirm the remote, branch, and depth of each clone +git -C .effect-reference/effect remote get-url origin +git -C .effect-reference/effect branch --show-current +git -C .effect-reference/effect rev-parse --is-shallow-repository +git -C .effect-reference/alchemy remote get-url origin +git -C .effect-reference/alchemy branch --show-current +git -C .effect-reference/alchemy rev-parse --is-shallow-repository +``` -# 4. Limpiar archivos eliminados en el origen -git add -A +If one of the clones already exists, do not run `git clone` again in that directory. Use the corresponding synchronization protocol. -# 5. Crear commit de sincronización -git commit -m "chore: sync latest effect-smol source" +### Protocol 2: Update from Upstream Sources -# 6. Sincronizar con el repositorio del usuario -git push origin effect-context +When the user asks to update the context, first confirm that each clone is clean and points to the expected remote and branch: -# 7. Volver a la raíz -cd .. +```bash +# Effect v4 +git -C .effect-reference/effect status --short --branch +git -C .effect-reference/effect remote get-url origin +git -C .effect-reference/effect branch --show-current +git -C .effect-reference/effect pull --ff-only --depth 1 origin main + +# Alchemy next/alpha +git -C .effect-reference/alchemy status --short --branch +git -C .effect-reference/alchemy remote get-url origin +git -C .effect-reference/alchemy branch --show-current +git -C .effect-reference/alchemy pull --ff-only --depth 1 origin main ``` -## Restricciones Críticas de Seguridad +Do not overwrite local changes in the clones. If `status --short` shows changes or `pull --ff-only` cannot fast-forward, stop and resolve the state explicitly. -| Restricción | Descripción | -| ----------------------- | --------------------------------------------------------------------------------------------------------------- | -| **Aislamiento Total** | Nunca hacer `git merge` entre la rama actual y `effect-context` | -| **Modo Solo-Lectura** | No sugerir cambios de código dentro de `./effect-reference` | -| **Limpieza de Commits** | Los archivos de `.effect-reference` NUNCA deben aparecer en `git status` de ramas de desarrollo (`dev`, `main`) | +## Critical Safety Constraints -## Verificación del Estado +| Constraint | Description | +| ------------------------ | ---------------------------------------------------------------------------------------------------------- | +| **Total Isolation** | Never run `git merge` between the reference clones and the development branches | +| **Read-Only Mode** | Do not suggest code changes inside `.effect-reference/effect` or `.effect-reference/alchemy` | +| **Independent Clones** | Do not mount these references as worktrees or maintain them through orphan branches of the main repository | +| **Ignored Content** | Files under `.effect-reference` must not be included in commits on development branches | +| **Safe Synchronization** | Update only clean clones by fast-forwarding from the `main` branch of their expected `origin` | -```bash -# Verificar que es un worktree válido -cd .effect-reference && git status - -# Ver ramas disponibles -git branch -a +## Status Verification -# Verificar que está en rama effect-context -git rev-parse --abbrev-ref HEAD +```bash +# Verify the remote, branch, and shallow clone status of Effect +git -C .effect-reference/effect remote get-url origin +git -C .effect-reference/effect branch --show-current +git -C .effect-reference/effect rev-parse --is-shallow-repository +git -C .effect-reference/effect rev-parse HEAD +git ls-remote https://github.com/Effect-TS/effect.git refs/heads/main + +# Verify the remote, branch, and shallow clone status of Alchemy +git -C .effect-reference/alchemy remote get-url origin +git -C .effect-reference/alchemy branch --show-current +git -C .effect-reference/alchemy rev-parse --is-shallow-repository +git -C .effect-reference/alchemy rev-parse HEAD +git ls-remote https://github.com/alchemy-run/alchemy.git refs/heads/main + +# Both clones must remain clean +git -C .effect-reference/effect status --short --branch +git -C .effect-reference/alchemy status --short --branch ``` ## Commands ```bash -# Setup inicial -git fetch origin effect-context && git worktree add .effect-reference origin/effect-context +# Initial setup +git clone --depth 1 --branch main https://github.com/Effect-TS/effect.git .effect-reference/effect +git clone --depth 1 --branch main https://github.com/alchemy-run/alchemy.git .effect-reference/alchemy -# Sincronizar con latest -cd .effect-reference && git fetch https://github.com/Effect-TS/effect-smol.git main --depth 1 && git checkout FETCH_HEAD -- . && git add -A && git commit -m "chore: sync latest effect-smol source" && git push origin effect-context && cd .. +# Synchronize with the latest upstream state +git -C .effect-reference/effect pull --ff-only --depth 1 origin main +git -C .effect-reference/alchemy pull --ff-only --depth 1 origin main -# Verificar estado -cd .effect-reference && git status && git branch +# Verify status +git -C .effect-reference/effect status --short --branch +git -C .effect-reference/alchemy status --short --branch ``` -## Recursos +## Resources -- **Referencia Effect**: [effect-reference/](./effect-reference/) -- **Guía de Migración**: [effect-reference/MIGRATION.md](./effect-reference/MIGRATION.md) +- **Effect v4 Reference**: [.effect-reference/effect/](../../../.effect-reference/effect/) +- **Effect Migration Guide**: [.effect-reference/effect/MIGRATION.md](../../../.effect-reference/effect/MIGRATION.md) +- **Alchemy next/alpha Reference**: [.effect-reference/alchemy/](../../../.effect-reference/alchemy/) diff --git a/.agent/skills/effect-pattern-discovery/SKILL.md b/.agent/skills/effect-pattern-discovery/SKILL.md index 3053f14b..ee699ae1 100644 --- a/.agent/skills/effect-pattern-discovery/SKILL.md +++ b/.agent/skills/effect-pattern-discovery/SKILL.md @@ -1,7 +1,7 @@ --- name: effect-pattern-discovery description: > - Effect-TS patterns sourced from effect-smol reference implementation. + Effect-TS patterns sourced from the Effect v4 reference on Effect-TS/effect main. Trigger: When implementing Effect, Layer, Schema, Pipe, Context, or error handling patterns. license: Apache-2.0 metadata: @@ -173,23 +173,31 @@ const AppLayer = UserServiceLive.pipe( const CombinedLayer = Layer.merge(UserServiceLive, DatabaseServiceLive) ``` +## Reference Selection + +- `.effect-reference/effect` is the ignored, standalone, depth-1 clone of `https://github.com/Effect-TS/effect.git` on `main` and is the canonical Effect v4 source. +- Effect v3 lives on the `v3` branch of the same `Effect-TS/effect` repository. +- `.effect-reference/alchemy` is the ignored, standalone, depth-1 clone of `https://github.com/alchemy-run/alchemy.git` on `main` and is the canonical Effect-based Alchemy next/alpha reference. +- `alchemy-run/alchemy-async` is the former async implementation; do not use it as the canonical current Alchemy reference. + ## Commands ```bash -# Scan for similar patterns in effect-smol -ls .effect-reference/packages/effect/src/ +# Scan for similar patterns in the Effect v4 reference +ls .effect-reference/effect/packages/effect/src/ # Check internal implementations -cat .effect-reference/packages/effect/src/internal/effect.ts +cat .effect-reference/effect/packages/effect/src/internal/effect.ts # Look at module exports -cat .effect-reference/packages/effect/src/index.ts +cat .effect-reference/effect/packages/effect/src/index.ts + +# Explore current Effect-based Alchemy patterns +ls .effect-reference/alchemy/ ``` ## Resources -- **effect-smol source**: `.effect-reference/packages/effect/src/` -- **Pattern docs**: `.effect-reference/.patterns/` -- **Error handling**: `.effect-reference/.patterns/error-handling.md` -- **Module organization**: `.effect-reference/.patterns/module-organization.md` -- **Library development**: `.effect-reference/.patterns/effect-library-development.md` +- **Effect v4 source**: `.effect-reference/effect/packages/effect/src/` +- **Effect migration guide**: `.effect-reference/effect/MIGRATION.md` +- **Alchemy next/alpha source**: `.effect-reference/alchemy/` diff --git a/.github/SETUP.md b/.github/SETUP.md index d8aacb43..3ab72ed0 100644 --- a/.github/SETUP.md +++ b/.github/SETUP.md @@ -1,274 +1,124 @@ -# 🚀 CI/CD Setup Guide +# CI and npm release setup -This guide explains how to set up the automated CI/CD pipeline for publishing packages to NPM and JSR (Deno). +Effectify has three intentionally separate release channels. Alpha and beta are branch-driven prereleases; stable publication is always a manual decision. -## 📋 Prerequisites +## Release channel map -1. **NPM Account**: You need an NPM account with publish permissions -2. **JSR Account**: You need a JSR account for Deno packages (optional) -3. **GitHub Repository**: With admin access to configure secrets +| Channel | Trigger | npm tag | Workflow | +| ------- | ---------------------------------------- | ------------------ | -------------------------------------- | +| Alpha | Push to `dev` | `alpha` | `.github/workflows/release-alpha.yml` | +| Beta | Push to `master` | `beta` | `.github/workflows/cd.yml` | +| Stable | Manual workflow against current `master` | default (`latest`) | `.github/workflows/release-stable.yml` | -## 🔐 Required Secrets +A `chore(release):` commit pushed by a release workflow does not start another beta publication. Stable has no push trigger and cannot be reached by a normal branch push. -Configure these secrets in your GitHub repository settings (`Settings > Secrets and variables > Actions`): +## Required repository setup -### NPM Token +Use Node.js 24.19.0 and pnpm 10.14.0 locally when reproducing workflow checks. -- **Name**: `NPM_TOKEN` -- **Description**: NPM authentication token for publishing packages -- **How to get**: - 1. Go to [npmjs.com](https://www.npmjs.com/) and log in - 2. Go to `Account Settings > Access Tokens` - 3. Click `Generate New Token` - 4. Select `Automation` type (for CI/CD) - 5. Copy the token and add it as `NPM_TOKEN` secret +Configure these GitHub Actions secrets under **Settings > Secrets and variables > Actions**: -### JSR OIDC Configuration (Recommended) +| Secret | Purpose | +| --------------- | ----------------------------------------------------------------------------------------- | +| `NPM_TOKEN` | npm authentication and provenance publication | +| `RELEASE_TOKEN` | Optional checkout token for stable release git operations; `GITHUB_TOKEN` is the fallback | -- **Method**: OIDC (OpenID Connect) - more secure than personal tokens -- **Setup**: Link your package to your GitHub repository in JSR -- **How to configure**: - 1. Go to your package `@effectify/solid-query` on [jsr.io](https://jsr.io/) - 2. Go to the **"Settings"** tab - 3. In **"GitHub repository"** field, enter your repository name (e.g., `your-username/effectify`) - 4. Click **"Link"** to connect the package to your repository - 5. No secrets needed in GitHub - OIDC handles authentication automatically -- **Reference**: [JSR Publishing from GitHub Actions](https://jsr.io/docs/publishing-packages#publishing-from-github-actions) +The release jobs request `contents: write` for Nx release commits, tags, and GitHub releases, and `id-token: write` for npm provenance. -## 🏗️ Workflow Overview +## Nx release projects -### CI Workflow (`.github/workflows/ci.yml`) +All release workflows derive their allowlist from `nx.json`. The seven current Nx project names are: -- **Triggers**: Push to `master`, `main`, `develop` branches and PRs -- **Purpose**: Development and PR validation -- **Jobs**: - - 🔍 **Lint & Format**: Checks code style and formatting - - 🔍 **Type Check**: Validates TypeScript types - - 🏗️ **Build**: Builds affected projects and uploads artifacts - - 🧪 **Test**: Runs tests for affected projects - - 📊 **Summary**: CI results dashboard +1. `@effectify/react-router` +2. `@effectify/react-query` +3. `@effectify/node-better-auth` +4. `@effectify/solid-query` +5. `@effectify/react-router-better-auth` +6. `@effectify/prisma` +7. `@effectify/hatchet` -### Release Workflow (`.github/workflows/release.yml`) +Use these project names—not filesystem paths—in manual workflow inputs. -- **Triggers**: Push to `master` branch only -- **Purpose**: Production release and publishing -- **Optimized**: Tries to reuse build artifacts from CI -- **Jobs**: - - 🔍 **Detect Changes**: Determines if release is needed - - 🚀 **Release & Publish**: Handles versioning, changelog, and publishing - - 📢 **Notify**: Provides status notifications +## Exact workflow behavior -## 🎯 How It Works +### CI: `.github/workflows/ci.yml` -### 1. Change Detection +**Triggers:** pull requests that are opened, synchronized, reopened, or marked ready for review, plus pushes to `dev`. -The workflow automatically detects if any of the configured release projects have changes: +For non-draft pull requests, CI runs the static release-policy contract, affected lint and format checks, affected type checks, affected builds, and affected tests. The release-policy contract is dependency-free and runs with Node.js 24.19.0: -- `packages/react/remix` -- `packages/react/router` -- `packages/node/better-auth` -- `packages/solid/query` - -### 2. Affected Projects - -Uses Nx's native `affected` commands to: - -- Build only changed projects: `nx affected --target=build` -- Test only changed projects: `nx affected --target=test` -- Lint only changed projects: `nx affected --target=lint` - -### 3. Release Process - -When changes are detected on master: - -1. **Try to reuse** build artifacts from CI (if available) -2. **Build** affected projects (only if artifacts not found) -3. **Test** affected projects -4. **Version** packages using Nx Release -5. **Generate** changelogs automatically -6. **Publish** to NPM and JSR -7. **Create** GitHub release - -## 🔧 Configuration Files - -### `.npmrc` - -```ini -registry=https://registry.npmjs.org/ -always-auth=true -@jsr:registry=https://npm.jsr.io/ -``` - -### `nx.json` (Release Configuration) - -```json -{ - "release": { - "projects": [ - "packages/react/remix", - "packages/react/router", - "packages/node/better-auth", - "packages/solid/query" - ], - "changelog": { - "projectChangelogs": { - "renderOptions": { - "authors": true, - "commitReferences": false, - "versionTitleDate": true, - "applyUsernameToAuthors": true - } - } - }, - "releaseTagPattern": "release/{version}", - "version": { - "preVersionCommand": "pnpm nx build @effectify/react-remix @effectify/react-router" - } - } -} +```bash +node --test scripts/release-policy-contract.test.mjs ``` -## 🚀 Usage +### Alpha: `.github/workflows/release-alpha.yml` -### Automatic Release +**Triggers:** pushes to `dev` and optional manual dispatch. -- Push changes to `master` branch -- The workflow automatically detects affected packages -- If changes are found, it triggers the release process +A normal run calculates projects affected across the GitHub push event's exact `before`-to-`github.sha` range, then intersects those exact project names with the seven-project release allowlist. Invalid or zero `before` SHAs safely fall back to the current commit's parent. If the intersection is empty, publication is skipped. Otherwise the workflow builds, tests, versions with Nx `--preid=alpha`, rebuilds the versioned packages, and publishes with npm `--tag=alpha`. -### Manual Release +Manual publish-only recovery requires an explicit comma-separated `projects` input. It publishes the selected existing manifests with `--tag=alpha` and skips version, changelog, and git mutation. -- Go to `Actions` tab in GitHub -- Select `🚀 Release & Publish` workflow -- Click `Run workflow` button +### Beta: `.github/workflows/cd.yml` -### Check Status +**Triggers:** pushes to `master` and optional manual dispatch. -- Go to `Actions` tab to see workflow status -- Check the `📊 CI Summary` for detailed results -- Review published packages in NPM and JSR +A normal run uses the same exact push-range and exact-membership affected-project policy as alpha, versions with Nx `--preid=beta`, and publishes with npm `--tag=beta`. It never publishes to npm's default tag. Pushes whose head commit contains `chore(release):` or `[skip release]` are skipped, preventing release-commit recursion. -## 🛠️ Troubleshooting +Manual publish-only recovery requires explicit existing project names and still publishes with `--tag=beta`; it skips version, changelog, and git mutation. -### Common Issues +### Stable: `.github/workflows/release-stable.yml` -1. **NPM Token Issues** +**Trigger:** manual dispatch only. The workflow has no push trigger. - - Ensure token has `Automation` type - - Check token permissions include publish access - - Verify token is not expired +The workflow always checks out `master`, fetches `origin/master`, and fails unless the checkout is the current remote commit. The `projects` input is required and is validated against all seven Nx release projects. -2. **JSR Token Issues** +#### Normal stable graduation - - Ensure JSR account has publish permissions - - Check if package name conflicts exist - - Verify JSR token is valid +1. Select one or more existing prerelease projects in the comma-separated `projects` input. +2. Leave `publish_only` disabled. +3. The workflow verifies the release-policy contract, exact checked-out HEAD equality with fetched `origin/master`, the selected-project allowlist, and npm authentication. +4. It builds and tests the selected projects, then runs React Router 8 tests, consolidation, readiness, and manifest verification. +5. Only after validation passes, Nx applies the relative `patch` specifier to the selected prereleases, producing their stable versions and release metadata. +6. Nx publishes only the selected projects without a prerelease dist-tag, so npm uses the stable default tag. -3. **Build Failures** +The workflow rejects a selected normal-mode project whose local manifest is already stable. This keeps graduation explicit and prevents an accidental extra patch release. - - Check if all dependencies are installed - - Verify TypeScript compilation - - Review test failures +#### Publish-only stable recovery -4. **No Release Triggered** - - Ensure changes are in release-configured projects - - Check if changes are in configuration files - - Verify branch is `master` +Use this only when selected stable versions already exist in the checked-out manifests but need publication retried: -## 🧪 Testing Workflows Locally +1. Enter the exact existing stable project names in `projects`. +2. Enable `publish_only`. +3. The workflow rejects missing versions, prerelease versions, unknown projects, and empty selections. +4. It builds, tests, and verifies before publishing the selected manifests. +5. It performs no version, changelog, tag, release commit, or git push mutation and supplies no prerelease npm dist-tag. -### Using Act (GitHub Actions Local Runner) +## Release safety checks -We've set up **act** to test GitHub Actions workflows locally before pushing to GitHub. - -#### Prerequisites +Before any Nx version or publish command, every release workflow runs: ```bash -# Install act (if not already installed) -brew install act - -# Install Docker (required for act) -# Download from https://www.docker.com/products/docker-desktop +node --test scripts/release-policy-contract.test.mjs ``` -#### Quick Testing +The contract rejects explicitly modeled structural regressions: a stable push trigger, missing beta or alpha prerelease flags, weakened project or current-`master` checks, known version/publish commands moving ahead of required validation, and channel documentation drifting from the workflows. -```bash -# Test all workflows -./scripts/test-workflows.sh - -# Test specific workflow -./scripts/test-workflows.sh ci -./scripts/test-workflows.sh release - -# List available workflows -./scripts/test-workflows.sh list - -# Get help -./scripts/test-workflows.sh help -``` - -#### Manual Testing with Act +React Router publication readiness is verified with the maintained React Router 8 project and example targets: ```bash -# List jobs in a workflow -act -W .github/workflows/ci.yml --list -act -W .github/workflows/release.yml --list - -# Run specific job -act -W .github/workflows/ci.yml -j build -act -W .github/workflows/ci.yml -j test - -# Run with M1/M2 Mac compatibility -act -W .github/workflows/ci.yml --container-architecture linux/amd64 - -# Run with local secrets -act -W .github/workflows/ci.yml --secret-file .secrets +pnpm nx test @effectify/react-router +pnpm nx run @effectify/react-router-example:migration:test +pnpm nx run @effectify/react-router-example:migration:verify +pnpm nx run @effectify/react-router-example:migration:manifest +pnpm nx run @effectify/react-router-example:consolidation:verify ``` -### Testing Nx Commands Locally - -```bash -# Check affected projects locally -pnpm nx show projects --affected --base=origin/master~1 --head=HEAD - -# Test release process locally -pnpm nx release --dry-run - -# Build affected projects locally -pnpm nx affected --target=build --base=origin/master~1 --head=HEAD - -# Test JSR publish locally (dry run) -cd packages/solid/query -pnpm dlx jsr publish --dry-run - -# Test JSR publish locally (real publish) -cd packages/solid/query -pnpm dlx jsr publish -``` - -### Local Testing Files - -- **`.actrc`**: Act configuration file -- **`.secrets`**: Local secrets for testing (not committed to git) -- **`scripts/test-workflows.sh`**: Helper script for testing workflows - -## 📚 Additional Resources - -- [Nx Release Documentation](https://nx.dev/nx-api/nx/documents/release) -- [Nx Affected Commands](https://nx.dev/nx-api/nx/documents/affected) -- [GitHub Actions Documentation](https://docs.github.com/en/actions) -- [NPM Publishing Guide](https://docs.npmjs.com/packages-and-modules/contributing-packages-to-the-registry) -- [JSR Publishing Guide](https://jsr.io/docs/publishing) - -## 🎉 Benefits +## Recovery checklist -- ✅ **Automated**: No manual publishing required -- ✅ **Optimized**: Reuses build artifacts when possible -- ✅ **Efficient**: Only builds and tests affected projects -- ✅ **Reliable**: Comprehensive testing before release -- ✅ **Transparent**: Clear changelogs and release notes -- ✅ **Multi-platform**: Supports both NPM and JSR (Deno) -- ✅ **Scalable**: Easy to add new packages to release process -- ✅ **Separated**: Clear separation between CI and Release concerns -- ✅ **Fast**: Parallel execution and smart artifact reuse +- Confirm the workflow run is using the intended channel. +- Copy exact project names from the seven-project list above. +- For alpha or beta recovery, confirm the existing versions carry the matching prerelease suffix. +- For stable recovery, confirm every selected manifest version has no prerelease suffix. +- Use publish-only mode only to retry existing versions; use normal stable mode to graduate prereleases. +- Review the workflow summary and npm package pages after completion. diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6150e093..3992e9bf 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,80 +1,191 @@ -name: 🚀 CD +name: 🚀 Release Beta on: push: branches: [master] + workflow_dispatch: + inputs: + publish_only: + description: "Publish existing beta versions without versioning, changelog, or git changes" + required: true + type: boolean + default: false + projects: + description: "Comma-separated existing Nx release project names; required for publish-only recovery" + required: false + type: string -permissions: - contents: write # Needed for creating commits and tags - id-token: write # Needed for npm provenance authentication +concurrency: + group: release-beta + cancel-in-progress: false + +env: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/effectify" jobs: - release: - name: 🚀 Release + release-beta: + name: 🚀 Release Beta + if: ${{ github.event_name == 'workflow_dispatch' || (!contains(github.event.head_commit.message, 'chore(release):') && !contains(github.event.head_commit.message, '[skip release]')) }} runs-on: ubuntu-latest - if: ${{ !contains(github.event.head_commit.message, 'chore(release):') && !contains(github.event.head_commit.message, '[skip release]') }} + permissions: + contents: write + id-token: write + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: effectify + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: 📥 Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: - fetch-depth: 0 # Important for Nx to analyze git history - token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - name: 📦 Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.14.0 - name: 🏗️ Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: 20 - registry-url: "https://registry.npmjs.org" + node-version: "24.19.0" cache: "pnpm" + registry-url: "https://registry.npmjs.org/" - name: 📦 Install dependencies run: pnpm install --frozen-lockfile - - name: ⚙️ Git Configuration + - name: 🛡️ Verify release policy contract + run: node --test scripts/release-policy-contract.test.mjs + + - name: 🔧 Configure Git + if: ${{ inputs.publish_only != true }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - - name: 🔖 Version & Changelog - # Calculates versions, updates changelogs, creates commit and tag + - name: 🔍 Detect Affected Release Projects + id: affected env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISH_ONLY: ${{ inputs.publish_only || false }} + RECOVERY_PROJECTS: ${{ inputs.projects || '' }} + BEFORE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} run: | - # Ensure git user is configured - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + RELEASE_PROJECTS=$( + jq -r '.release.projects[]' nx.json | while read -r path; do + pnpm nx show project "$path" --json | jq -r '.name' + done | jq -Rsc 'split("\n") | map(select(length > 0)) | unique' + ) - # Run versioning (Nx modifies files locally) - pnpm nx release version --git-commit --git-tag + if [ "$PUBLISH_ONLY" = "true" ]; then + if [ -z "$RECOVERY_PROJECTS" ]; then + echo "publish-only recovery requires an explicit comma-separated projects input" >&2 + exit 1 + fi + SELECTED_PROJECTS=$(printf '%s' "$RECOVERY_PROJECTS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d' | sort -u) + while IFS= read -r project; do + if ! printf '%s\n' "$RELEASE_PROJECTS" | jq -r '.[]' | grep -Fx -- "$project" >/dev/null; then + echo "Invalid release project: $project" >&2 + exit 1 + fi + done <<< "$SELECTED_PROJECTS" + echo "has_projects=true" >> "$GITHUB_OUTPUT" + echo "projects=$(printf '%s' "$SELECTED_PROJECTS" | paste -sd, -)" >> "$GITHUB_OUTPUT" + exit 0 + fi - # Manual Safety Net: Commit and Push - # 1. If Nx made a commit but didn't push -> We push. - # 2. If Nx modified files but didn't commit -> We commit and push. + ZERO_SHA="0000000000000000000000000000000000000000" + BEFORE="$BEFORE_SHA" + HEAD="$HEAD_SHA" + if ! git cat-file -e "${HEAD}^{commit}" 2>/dev/null; then + HEAD=$(git rev-parse HEAD) + fi + if [ -n "$BEFORE" ] && [ "$BEFORE" != "$ZERO_SHA" ] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + BASE="$BEFORE" + elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then + BASE="HEAD^" + else + BASE="$HEAD" + fi - if [[ -n $(git status -s) ]]; then - echo "📝 Changes detected (Nx didn't commit). Committing manually..." - git add . - git commit -m "chore(release): publish [skip ci]" + AFFECTED_RAW=$(pnpm nx show projects --affected --base="$BASE" --head="$HEAD" --json 2>/dev/null || echo "[]") + AFFECTED_RELEASE_PROJECTS=$(echo "$AFFECTED_RAW" | jq -r --argjson release "$RELEASE_PROJECTS" '[.[] | select(. as $project | $release | index($project))] | unique | join(",")' 2>/dev/null || echo "") + if [ -z "$AFFECTED_RELEASE_PROJECTS" ] || [ "$AFFECTED_RELEASE_PROJECTS" = "null" ]; then + echo "has_projects=false" >> "$GITHUB_OUTPUT" + echo "projects=" >> "$GITHUB_OUTPUT" else - echo "✅ No uncommitted changes found (Nx likely committed)." + echo "has_projects=true" >> "$GITHUB_OUTPUT" + echo "projects=$AFFECTED_RELEASE_PROJECTS" >> "$GITHUB_OUTPUT" fi - echo "🚀 Pushing changes and tags to remote..." - git push origin HEAD --follow-tags + - name: 🏗️ Build Affected Projects + if: ${{ steps.affected.outputs.has_projects == 'true' }} + env: + PROJECTS: ${{ steps.affected.outputs.projects }} + run: pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 - - name: 🏗️ Build - # Builds projects with the updated version before publishing - run: pnpm nx run-many -t build --all + - name: 🧪 Test Affected Projects + if: ${{ steps.affected.outputs.has_projects == 'true' }} + env: + PROJECTS: ${{ steps.affected.outputs.projects }} + run: pnpm nx run-many -t test "--projects=$PROJECTS" --parallel=3 --passWithNoTests - - name: 🚀 Publish + - name: ✅ Verify React Router 8 readiness + if: ${{ steps.affected.outputs.has_projects == 'true' }} + run: | + pnpm nx test @effectify/react-router + pnpm nx run @effectify/react-router-example:migration:test + pnpm nx run @effectify/react-router-example:migration:verify + pnpm nx run @effectify/react-router-example:migration:manifest + pnpm nx run @effectify/react-router-example:consolidation:verify + + - name: 🔐 Verify npm authentication + if: ${{ steps.affected.outputs.has_projects == 'true' }} + run: npm whoami + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: 🚀 Version, Changelog & Publish Beta + if: ${{ steps.affected.outputs.has_projects == 'true' }} env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + PROJECTS: ${{ steps.affected.outputs.projects }} + PUBLISH_ONLY: ${{ inputs.publish_only || false }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed for creating GitHub Releases - # Publishes packages to npm and creates GitHub Releases - run: pnpm nx release publish + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_CONFIG_PROVENANCE: true + run: | + if [ "$PUBLISH_ONLY" != "true" ]; then + pnpm nx release "--projects=$PROJECTS" --preid=beta --skip-publish + pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 + fi + # Every beta publish is explicitly non-default, including recovery. + pnpm nx release publish "--projects=$PROJECTS" --tag=beta + + - name: 📊 Release Summary + if: always() + env: + HAS_PROJECTS: ${{ steps.affected.outputs.has_projects }} + PROJECTS: ${{ steps.affected.outputs.projects }} + PUBLISH_ONLY: ${{ inputs.publish_only || false }} + run: | + echo "## 🚀 Beta Release Summary" >> "$GITHUB_STEP_SUMMARY" + if [ "$HAS_PROJECTS" = "true" ]; then + echo "**Projects:** $PROJECTS" >> "$GITHUB_STEP_SUMMARY" + if [ "$PUBLISH_ONLY" = "true" ]; then + echo "**Mode:** publish-only recovery; selected existing manifests were built and published with the beta tag." >> "$GITHUB_STEP_SUMMARY" + fi + else + echo "⏭️ **Skipped - No affected projects**" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ec0e667..b0c44f85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: 🧪 CI on: pull_request: - branches: [master, dev] + types: [opened, synchronize, reopened, ready_for_review] push: branches: [dev] @@ -15,115 +15,189 @@ env: NODE_OPTIONS: "--max-old-space-size=4096" jobs: + release-policy: + name: 🛡️ Release Policy Contract + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: 📥 Checkout + uses: actions/checkout@v5 + + - name: 🏗️ Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "24.19.0" + package-manager-cache: false + + - name: 🛡️ Verify release policy contract + run: node --test scripts/release-policy-contract.test.mjs + # Lint and format check lint: name: 🔍 Lint & Format + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - name: 📥 Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: 📦 Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.14.0 - name: 🏗️ Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: "20" + node-version: "24.19.0" cache: "pnpm" - name: 📦 Install dependencies run: pnpm install --frozen-lockfile - - name: 🔍 Run oxlint on affected projects + - name: 🎯 Resolve affected base + id: affected-base + shell: bash run: | if [[ "${{ github.event_name }}" == "pull_request" ]]; then BASE="origin/${{ github.base_ref }}" else - BASE="HEAD~1" + BEFORE="${{ github.event.before }}" + DEFAULT_BASE="origin/${{ github.event.repository.default_branch }}" + ZERO_SHA="0000000000000000000000000000000000000000" + if [[ -n "$BEFORE" && "$BEFORE" != "$ZERO_SHA" ]] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + BASE="$BEFORE" + elif git cat-file -e "${DEFAULT_BASE}^{commit}" 2>/dev/null; then + BASE="$DEFAULT_BASE" + elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then + BASE="HEAD^" + else + BASE="HEAD" + fi fi - pnpm nx affected --target=lint --base=$BASE --head=HEAD --parallel=1 + echo "sha=$BASE" >> "$GITHUB_OUTPUT" + + - name: 🔍 Run oxlint on affected projects + run: pnpm nx affected --target=lint --base="${{ steps.affected-base.outputs.sha }}" --head=HEAD --parallel=1 - - name: 🎨 Check code formatting with dprint - run: pnpm exec dprint check || (echo "❌ Some files are not formatted. Run 'pnpm exec dprint fmt' to fix formatting." && exit 1) + - name: 🎨 Check changed files with oxfmt + run: pnpm nx run @effectify/repo:format:check --args="--base=${{ steps.affected-base.outputs.sha }} --head=HEAD" || (echo "❌ Some changed files are not formatted. Run 'pnpm format' to fix formatting." && exit 1) # Type checking typecheck: name: 🔍 Type Check + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - name: 📥 Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: 📦 Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.14.0 - name: 🏗️ Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: "20" + node-version: "24.19.0" cache: "pnpm" - name: 📦 Install dependencies run: pnpm install --frozen-lockfile - - name: 🔍 Type check affected projects + - name: 🎯 Resolve affected base + id: affected-base + shell: bash run: | if [[ "${{ github.event_name }}" == "pull_request" ]]; then BASE="origin/${{ github.base_ref }}" else - BASE="HEAD~1" + BEFORE="${{ github.event.before }}" + DEFAULT_BASE="origin/${{ github.event.repository.default_branch }}" + ZERO_SHA="0000000000000000000000000000000000000000" + if [[ -n "$BEFORE" && "$BEFORE" != "$ZERO_SHA" ]] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + BASE="$BEFORE" + elif git cat-file -e "${DEFAULT_BASE}^{commit}" 2>/dev/null; then + BASE="$DEFAULT_BASE" + elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then + BASE="HEAD^" + else + BASE="HEAD" + fi fi - pnpm nx affected --target=typecheck --base=$BASE --head=HEAD --parallel=1 --verbose + echo "sha=$BASE" >> "$GITHUB_OUTPUT" + + - name: 🔍 Type check affected projects + run: pnpm nx affected --target=typecheck --base="${{ steps.affected-base.outputs.sha }}" --head=HEAD --parallel=1 --verbose # Build affected projects build: name: 🏗️ Build + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest outputs: has-build-artifacts: ${{ steps.build.outputs.has-artifacts }} steps: - name: 📥 Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: 📦 Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.14.0 - name: 🏗️ Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: "20" + node-version: "24.19.0" cache: "pnpm" - name: 📦 Install dependencies run: pnpm install --frozen-lockfile + - name: 🎯 Resolve affected base + id: affected-base + shell: bash + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE="origin/${{ github.base_ref }}" + else + BEFORE="${{ github.event.before }}" + DEFAULT_BASE="origin/${{ github.event.repository.default_branch }}" + ZERO_SHA="0000000000000000000000000000000000000000" + if [[ -n "$BEFORE" && "$BEFORE" != "$ZERO_SHA" ]] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + BASE="$BEFORE" + elif git cat-file -e "${DEFAULT_BASE}^{commit}" 2>/dev/null; then + BASE="$DEFAULT_BASE" + elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then + BASE="HEAD^" + else + BASE="HEAD" + fi + fi + echo "sha=$BASE" >> "$GITHUB_OUTPUT" + - name: 🏗️ Build affected projects id: build run: | - BASE=$(git merge-base HEAD origin/dev) + BASE="${{ steps.affected-base.outputs.sha }}" # Build affected projects with caching - pnpm nx affected --target=build --base=$BASE --head=HEAD --parallel=1 --verbose + pnpm nx affected --target=build --base="$BASE" --head=HEAD --parallel=1 --verbose # Check if any projects were built - AFFECTED_PROJECTS=$(pnpm nx show projects --affected --base=$BASE --head=HEAD --json | jq -r '.[]' | tr '\n' ' ') + AFFECTED_PROJECTS=$(pnpm nx show projects --affected --base="$BASE" --head=HEAD --json | jq -r '.[]' | tr '\n' ' ') if [ -n "$AFFECTED_PROJECTS" ]; then echo "has-artifacts=true" >> $GITHUB_OUTPUT echo "📦 Built projects: $AFFECTED_PROJECTS" - + # Create build manifest for artifact upload echo "BUILT_PROJECTS=$AFFECTED_PROJECTS" >> $GITHUB_ENV else @@ -146,28 +220,51 @@ jobs: # Test affected projects test: name: 🧪 Test + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest needs: [build] steps: - name: 📥 Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: 📦 Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.14.0 - name: 🏗️ Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: "20" + node-version: "24.19.0" cache: "pnpm" - name: 📦 Install dependencies run: pnpm install --frozen-lockfile + - name: 🎯 Resolve affected base + id: affected-base + shell: bash + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE="origin/${{ github.base_ref }}" + else + BEFORE="${{ github.event.before }}" + DEFAULT_BASE="origin/${{ github.event.repository.default_branch }}" + ZERO_SHA="0000000000000000000000000000000000000000" + if [[ -n "$BEFORE" && "$BEFORE" != "$ZERO_SHA" ]] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + BASE="$BEFORE" + elif git cat-file -e "${DEFAULT_BASE}^{commit}" 2>/dev/null; then + BASE="$DEFAULT_BASE" + elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then + BASE="HEAD^" + else + BASE="HEAD" + fi + fi + echo "sha=$BASE" >> "$GITHUB_OUTPUT" + - name: 📦 Download build artifacts if: needs.build.outputs.has-build-artifacts == 'true' uses: actions/download-artifact@v4 @@ -175,22 +272,21 @@ jobs: name: build-artifacts-${{ github.sha }} - name: 🧪 Test affected projects - run: | - BASE=$(git merge-base HEAD origin/dev) - pnpm nx affected --target=test --base=$BASE --head=HEAD --parallel=1 --verbose --passWithNoTests + run: pnpm nx affected --target=test --base="${{ steps.affected-base.outputs.sha }}" --head=HEAD --parallel=1 --verbose --passWithNoTests # Summary job ci-summary: name: 📊 CI Summary runs-on: ubuntu-latest - needs: [lint, typecheck, build, test] - if: always() + needs: [release-policy, lint, typecheck, build, test] + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) steps: - name: 📊 CI Summary run: | echo "## 🧪 CI Results Summary" >> $GITHUB_STEP_SUMMARY echo "| Job | Status | Details |" >> $GITHUB_STEP_SUMMARY echo "|-----|--------|---------|" >> $GITHUB_STEP_SUMMARY - echo "| 🔍 Lint & Format | ${{ needs.lint.result == 'success' && '✅ Success' || '❌ Failed' }} | Uses oxlint + dprint |" >> $GITHUB_STEP_SUMMARY + echo "| 🛡️ Release Policy | ${{ needs.release-policy.result == 'success' && '✅ Success' || '❌ Failed' }} | Enforces alpha, beta, and stable separation |" >> $GITHUB_STEP_SUMMARY + echo "| 🔍 Lint & Format | ${{ needs.lint.result == 'success' && '✅ Success' || '❌ Failed' }} | Uses oxlint + oxfmt |" >> $GITHUB_STEP_SUMMARY echo "| 🔍 Type Check | ${{ needs.typecheck.result == 'success' && '✅ Success' || '❌ Failed' }} | TypeScript compiler checks |" >> $GITHUB_STEP_SUMMARY echo "| 🏗️ Build | ${{ needs.build.result == 'success' && '✅ Success' || '❌ Failed' }} | Nx affected build |" >> $GITHUB_STEP_SUMMARY echo "| 🧪 Test | ${{ needs.test.result == 'success' && '✅ Success' || '❌ Failed' }} | Unit tests with Vitest |" >> $GITHUB_STEP_SUMMARY @@ -204,6 +300,6 @@ jobs: if [[ "${{ needs.lint.result }}" != "success" ]]; then echo "" >> $GITHUB_STEP_SUMMARY echo "### ❌ Linting Issues" >> $GITHUB_STEP_SUMMARY - echo "Please run 'pnpm exec dprint fmt' to fix formatting issues" >> $GITHUB_STEP_SUMMARY + echo "Please run 'pnpm format' to fix formatting issues" >> $GITHUB_STEP_SUMMARY fi # NEW: Add this step at the end of your job diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d2e11140..66c7bbff 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,19 +22,19 @@ jobs: runs-on: ubuntu-latest steps: - name: 📥 Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: 📦 Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.14.0 - name: 🏗️ Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: "20" + node-version: "24.19.0" cache: "pnpm" - name: 📦 Install dependencies diff --git a/.github/workflows/release-alpha.yml b/.github/workflows/release-alpha.yml index c53ed9d2..f8e656ec 100644 --- a/.github/workflows/release-alpha.yml +++ b/.github/workflows/release-alpha.yml @@ -4,10 +4,20 @@ on: push: branches: [dev] workflow_dispatch: + inputs: + publish_only: + description: "Publish existing alpha versions without versioning, changelog, or git changes" + required: true + type: boolean + default: false + projects: + description: "Comma-separated existing Nx release project names; required for publish-only recovery" + required: false + type: string concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: release-alpha + cancel-in-progress: false env: DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/effectify" @@ -34,185 +44,139 @@ jobs: --health-timeout 5s --health-retries 5 steps: - # Setup - name: 📥 Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: - fetch-depth: 0 # Full history needed for nx affected + fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: 📦 Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.14.0 - name: 🏗️ Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: "20" + node-version: "24.19.0" cache: "pnpm" registry-url: "https://registry.npmjs.org/" - name: 📦 Install dependencies run: pnpm install --frozen-lockfile + - name: 🛡️ Verify release policy contract + run: node --test scripts/release-policy-contract.test.mjs + - name: 🔧 Configure Git + if: ${{ inputs.publish_only != true }} run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" - # Detect affected projects (exit early if none) - name: 🔍 Detect Affected Release Projects id: affected + env: + PUBLISH_ONLY: ${{ inputs.publish_only || false }} + RECOVERY_PROJECTS: ${{ inputs.projects || '' }} + BEFORE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} run: | - # Get all release projects from nx.json (these are paths like "packages/solid/query") - RELEASE_PATHS=$(jq -r '.release.projects[]' nx.json | sort | uniq) - echo "📋 Release project paths:" - echo "$RELEASE_PATHS" - echo "" - - # Convert paths to project names - RELEASE_PROJECTS="" - for path in $RELEASE_PATHS; do - PROJECT_NAME=$(pnpm nx show project "$path" --json 2>/dev/null | jq -r '.name' 2>/dev/null) - if [ -n "$PROJECT_NAME" ] && [ "$PROJECT_NAME" != "null" ]; then - RELEASE_PROJECTS="$RELEASE_PROJECTS $PROJECT_NAME" + RELEASE_PROJECTS=$( + jq -r '.release.projects[]' nx.json | while read -r path; do + pnpm nx show project "$path" --json | jq -r '.name' + done | jq -Rsc 'split("\n") | map(select(length > 0)) | unique' + ) + + if [ "$PUBLISH_ONLY" = "true" ]; then + if [ -z "$RECOVERY_PROJECTS" ]; then + echo "publish-only recovery requires an explicit comma-separated projects input" >&2 + exit 1 fi - done - RELEASE_PROJECTS=$(echo "$RELEASE_PROJECTS" | tr ' ' '\n' | sort | uniq) - echo "📋 Release project names:" - echo "$RELEASE_PROJECTS" - echo "" - - # Get affected projects since last push to dev - AFFECTED_RAW=$(pnpm nx show projects --affected --base=origin/dev~1 --head=HEAD --json 2>/dev/null || echo "[]") - echo "🔍 All affected projects:" - echo "$AFFECTED_RAW" | jq -r '.[]' 2>/dev/null || echo "(none)" - echo "" + SELECTED_PROJECTS=$(printf '%s' "$RECOVERY_PROJECTS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d' | sort -u) + while IFS= read -r project; do + if ! printf '%s\n' "$RELEASE_PROJECTS" | jq -r '.[]' | grep -Fx -- "$project" >/dev/null; then + echo "Invalid release project: $project" >&2 + exit 1 + fi + done <<< "$SELECTED_PROJECTS" + AFFECTED_RELEASE_PROJECTS=$(printf '%s' "$SELECTED_PROJECTS" | paste -sd, -) + echo "has_projects=true" >> "$GITHUB_OUTPUT" + echo "projects=$AFFECTED_RELEASE_PROJECTS" >> "$GITHUB_OUTPUT" + exit 0 + fi - # Filter affected projects that are in release list - AFFECTED_RELEASE_PROJECTS=$(echo "$AFFECTED_RAW" | jq -r --arg release "$RELEASE_PROJECTS" '[.[] | select(. as $p | $release | contains($p))] | join(",")' 2>/dev/null || echo "") + ZERO_SHA="0000000000000000000000000000000000000000" + BEFORE="$BEFORE_SHA" + HEAD="$HEAD_SHA" + if ! git cat-file -e "${HEAD}^{commit}" 2>/dev/null; then + HEAD=$(git rev-parse HEAD) + fi + if [ -n "$BEFORE" ] && [ "$BEFORE" != "$ZERO_SHA" ] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + BASE="$BEFORE" + elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then + BASE="HEAD^" + else + BASE="$HEAD" + fi + AFFECTED_RAW=$(pnpm nx show projects --affected --base="$BASE" --head="$HEAD" --json 2>/dev/null || echo "[]") + AFFECTED_RELEASE_PROJECTS=$(echo "$AFFECTED_RAW" | jq -r --argjson release "$RELEASE_PROJECTS" '[.[] | select(. as $project | $release | index($project))] | unique | join(",")' 2>/dev/null || echo "") if [ -z "$AFFECTED_RELEASE_PROJECTS" ] || [ "$AFFECTED_RELEASE_PROJECTS" = "null" ]; then - echo "⚠️ No affected release projects found - skipping release" - echo "has_projects=false" >> $GITHUB_OUTPUT - echo "projects=" >> $GITHUB_OUTPUT - exit 0 + echo "has_projects=false" >> "$GITHUB_OUTPUT" + echo "projects=" >> "$GITHUB_OUTPUT" else - echo "✅ Affected release projects: $AFFECTED_RELEASE_PROJECTS" - echo "has_projects=true" >> $GITHUB_OUTPUT - echo "projects=$AFFECTED_RELEASE_PROJECTS" >> $GITHUB_OUTPUT + echo "has_projects=true" >> "$GITHUB_OUTPUT" + echo "projects=$AFFECTED_RELEASE_PROJECTS" >> "$GITHUB_OUTPUT" fi - # Build affected projects (skipped if no affected projects) - name: 🏗️ Build Affected Projects if: ${{ steps.affected.outputs.has_projects == 'true' }} - run: | - echo "Building: ${{ steps.affected.outputs.projects }}" - pnpm nx run-many -t build --projects=${{ steps.affected.outputs.projects }} --parallel=3 + env: + PROJECTS: ${{ steps.affected.outputs.projects }} + run: pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 - name: 🧪 Test Affected Projects if: ${{ steps.affected.outputs.has_projects == 'true' }} - run: | - echo "Testing: ${{ steps.affected.outputs.projects }}" - pnpm nx run-many -t test --projects=${{ steps.affected.outputs.projects }} --parallel=3 --passWithNoTests + env: + PROJECTS: ${{ steps.affected.outputs.projects }} + run: pnpm nx run-many -t test "--projects=$PROJECTS" --parallel=3 --passWithNoTests - # Release: version bump + publish (skipped if no affected projects) - - name: 🚀 Version & Publish Alpha + - name: 🔐 Verify npm authentication if: ${{ steps.affected.outputs.has_projects == 'true' }} - run: | - PROJECTS="${{ steps.affected.outputs.projects }}" - echo "Releasing: $PROJECTS" - - # Version bump with preid=alpha → creates versions like 0.0.5-alpha.0 - pnpm nx release version --projects=$PROJECTS --preid=alpha - - # Rebuild with new versions (version bump updates package.json) - pnpm nx run-many -t build --projects=$PROJECTS --parallel=3 - - # Publish to npm with alpha tag - pnpm nx release publish --projects=$PROJECTS --tag=alpha - - # Generate changelog, create GitHub Releases and tags - # For independent versioning (multiple projects with different versions), - # iterate over each project and pass its specific version - for PROJECT in $(echo "$PROJECTS" | tr ',' '\n'); do - # Get project root path and version from package.json - PROJ_PATH=$(pnpm nx show project "$PROJECT" --json | jq -r '.root') - VERSION=$(cat "$PROJ_PATH/package.json" | jq -r '.version') - - echo "Generating changelog for $PROJECT@$VERSION" - pnpm nx release changelog --projects=$PROJECT --version=$VERSION --git-tag --git-push - done - - # Generate workspace-level CHANGELOG.md aggregating all package changelogs - # nx release changelog already creates packages/*/CHANGELOG.md and GitHub releases - { - echo "# Changelog" - echo "" - echo "All notable changes to this project will be documented in this file." - echo "" - echo "This changelog summarizes releases for the following packages:" - echo "" - for PROJECT in $(echo "$PROJECTS" | tr ',' '\n'); do - echo "- @$PROJECT" - done - echo "" - } > CHANGELOG.md - - for PROJECT in $(echo "$PROJECTS" | tr ',' '\n'); do - # Extract package name without scope (e.g., @effectify/react-query -> react-query) - PKG_NAME=$(echo "$PROJECT" | sed 's/^@effectify\///') - CHANGELOG_PATH="packages/$PKG_NAME/CHANGELOG.md" - - if [ -f "$CHANGELOG_PATH" ]; then - # Extract first version entry from package changelog - FIRST_ENTRY=$(awk '/^## [0-9]/ && !first {first=1; print} first && /^## [0-9]/ {exit}' "$CHANGELOG_PATH" 2>/dev/null || echo "") - if [ -n "$FIRST_ENTRY" ]; then - echo "## @$PROJECT" >> CHANGELOG.md - echo "" >> CHANGELOG.md - echo "$FIRST_ENTRY" >> CHANGELOG.md - echo "" >> CHANGELOG.md - fi - fi - done - - # Commit and push workspace changelog - if [ -s CHANGELOG.md ] && [ "$(wc -l < CHANGELOG.md)" -gt 10 ]; then - git add CHANGELOG.md - git commit -m "chore: update workspace changelog" || echo "No changes to workspace changelog" - git push origin HEAD - fi + run: npm whoami + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - # Push git tags and version commits - git push origin HEAD --follow-tags + - name: 🚀 Version, Changelog & Publish Alpha + if: ${{ steps.affected.outputs.has_projects == 'true' }} env: + PROJECTS: ${{ steps.affected.outputs.projects }} + PUBLISH_ONLY: ${{ inputs.publish_only || false }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_CONFIG_PROVENANCE: true + run: | + if [ "$PUBLISH_ONLY" != "true" ]; then + pnpm nx release "--projects=$PROJECTS" --preid=alpha --skip-publish + pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 + fi + # Publish-only mode builds selected existing manifests and performs no git mutation. + pnpm nx release publish "--projects=$PROJECTS" --tag=alpha - # Summary - name: 📊 Release Summary if: always() + env: + HAS_PROJECTS: ${{ steps.affected.outputs.has_projects }} + PROJECTS: ${{ steps.affected.outputs.projects }} + PUBLISH_ONLY: ${{ inputs.publish_only || false }} run: | - echo "## 🚀 Alpha Release Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - if [ "${{ steps.affected.outputs.has_projects }}" = "true" ]; then - echo "✅ **Release Completed**" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Projects:** ${{ steps.affected.outputs.projects }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Step | Status |" >> $GITHUB_STEP_SUMMARY - echo "|------|--------|" >> $GITHUB_STEP_SUMMARY - echo "| Detect Affected | ✅ |" >> $GITHUB_STEP_SUMMARY - echo "| Build | ✅ |" >> $GITHUB_STEP_SUMMARY - echo "| Version Bump | ✅ With preid=alpha |" >> $GITHUB_STEP_SUMMARY - echo "| NPM Tag | alpha |" >> $GITHUB_STEP_SUMMARY - echo "| Changelog | ✅ Package + Workspace |" >> $GITHUB_STEP_SUMMARY - echo "| GitHub Release | ✅ Created |" >> $GITHUB_STEP_SUMMARY - echo "| Provenance | ✅ Enabled |" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "📦 Packages published with \"@alpha\" dist-tag and semver prerelease versions (x.x.x-alpha.x)." >> $GITHUB_STEP_SUMMARY + echo "## 🚀 Alpha Release Summary" >> "$GITHUB_STEP_SUMMARY" + if [ "$HAS_PROJECTS" = "true" ]; then + echo "**Projects:** $PROJECTS" >> "$GITHUB_STEP_SUMMARY" + if [ "$PUBLISH_ONLY" = "true" ]; then + echo "**Mode:** publish-only recovery; selected existing manifests were built and published with the alpha tag." >> "$GITHUB_STEP_SUMMARY" + fi else - echo "⏭️ **Skipped - No affected projects**" >> $GITHUB_STEP_SUMMARY + echo "⏭️ **Skipped - No affected projects**" >> "$GITHUB_STEP_SUMMARY" fi diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml new file mode 100644 index 00000000..d40a3b14 --- /dev/null +++ b/.github/workflows/release-stable.yml @@ -0,0 +1,184 @@ +name: 🚀 Release Stable + +on: + workflow_dispatch: + inputs: + projects: + description: "Comma-separated Nx release project names to graduate or recover" + required: true + type: string + publish_only: + description: "Publish selected existing stable versions without version, tag, changelog, or git mutation" + required: true + type: boolean + default: false + +concurrency: + group: release-stable + cancel-in-progress: false + +env: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/effectify" + +jobs: + release-stable: + name: 🚀 Release Stable + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: effectify + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: 📥 Checkout current master + uses: actions/checkout@v5 + with: + ref: master + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} + + - name: 🔒 Confirm current master + run: | + git fetch origin master --no-tags + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" || { + echo "Stable release checkout is not current origin/master" >&2 + exit 1 + } + + - name: 📦 Install pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.14.0 + + - name: 🏗️ Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "24.19.0" + cache: "pnpm" + registry-url: "https://registry.npmjs.org/" + + - name: 📦 Install dependencies + run: pnpm install --frozen-lockfile + + - name: 🛡️ Verify release policy contract + run: node --test scripts/release-policy-contract.test.mjs + + - name: 🔍 Validate Explicit Stable Projects + id: selected + env: + REQUESTED_PROJECTS: ${{ inputs.projects }} + PUBLISH_ONLY: ${{ inputs.publish_only }} + run: | + if [ -z "$REQUESTED_PROJECTS" ]; then + echo "Stable release requires explicit selected projects" >&2 + exit 1 + fi + + RELEASE_PROJECTS=$(jq -r '.release.projects[]' nx.json | while read -r path; do + pnpm nx show project "$path" --json | jq -r '.name' + done | sort -u) + SELECTED_PROJECTS=$(printf '%s' "$REQUESTED_PROJECTS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d' | sort -u) + + if [ -z "$SELECTED_PROJECTS" ]; then + echo "Stable release requires at least one selected project" >&2 + exit 1 + fi + + while IFS= read -r project; do + if ! printf '%s\n' "$RELEASE_PROJECTS" | grep -Fx -- "$project" >/dev/null; then + echo "Invalid release project: $project" >&2 + exit 1 + fi + + PROJECT_ROOT=$(pnpm nx show project "$project" --json | jq -r '.root') + VERSION=$(jq -r '.version // empty' "$PROJECT_ROOT/package.json") + if [ -z "$VERSION" ]; then + echo "Release project has no manifest version: $project" >&2 + exit 1 + fi + + if [ "$PUBLISH_ONLY" = "true" ]; then + # publish-only recovery requires explicit selected existing stable projects. + if [[ "$VERSION" == *-* ]]; then + echo "Publish-only stable recovery rejects prerelease version $project@$VERSION" >&2 + exit 1 + fi + elif [[ "$VERSION" != *-* ]]; then + echo "Normal stable release only graduates selected prereleases: $project@$VERSION" >&2 + exit 1 + fi + done <<< "$SELECTED_PROJECTS" + + echo "projects=$(printf '%s' "$SELECTED_PROJECTS" | paste -sd, -)" >> "$GITHUB_OUTPUT" + + - name: 🔧 Configure Git for graduation + if: ${{ inputs.publish_only != true }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: 🔐 Verify npm authentication + run: npm whoami + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: 🏗️ Build Selected Projects + env: + PROJECTS: ${{ steps.selected.outputs.projects }} + run: pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 + + - name: 🧪 Test Selected Projects + env: + PROJECTS: ${{ steps.selected.outputs.projects }} + run: pnpm nx run-many -t test "--projects=$PROJECTS" --parallel=3 --passWithNoTests + + - name: ✅ Verify React Router 8 readiness + run: | + pnpm nx test @effectify/react-router + pnpm nx run @effectify/react-router-example:migration:test + pnpm nx run @effectify/react-router-example:migration:verify + pnpm nx run @effectify/react-router-example:migration:manifest + pnpm nx run @effectify/react-router-example:consolidation:verify + + - name: 🔖 Graduate Selected Prereleases + if: ${{ inputs.publish_only != true }} + env: + PROJECTS: ${{ steps.selected.outputs.projects }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Relative patch removes the prerelease suffix without selecting unrequested projects. + run: pnpm nx release patch "--projects=$PROJECTS" --skip-publish + + - name: 🚀 Publish Stable + env: + PROJECTS: ${{ steps.selected.outputs.projects }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_CONFIG_PROVENANCE: true + # No prerelease dist-tag is supplied: npm's default stable tag is intentional. + run: pnpm nx release publish "--projects=$PROJECTS" + + - name: 📊 Release Summary + if: always() + env: + PROJECTS: ${{ steps.selected.outputs.projects }} + PUBLISH_ONLY: ${{ inputs.publish_only }} + run: | + echo "## 🚀 Stable Release Summary" >> "$GITHUB_STEP_SUMMARY" + echo "**Projects:** $PROJECTS" >> "$GITHUB_STEP_SUMMARY" + if [ "$PUBLISH_ONLY" = "true" ]; then + echo "**Mode:** publish-only recovery of selected existing stable versions; no version, tag, changelog, or git mutation was requested." >> "$GITHUB_STEP_SUMMARY" + else + echo "**Mode:** selected prereleases graduated with Nx relative patch." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..60ade1ae --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.19.0 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..8b6c08c1 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,36 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": false, + "singleQuote": false, + "jsxSingleQuote": false, + "trailingComma": "all", + "arrowParens": "always", + "endOfLine": "lf", + "proseWrap": "preserve", + "embeddedLanguageFormatting": "off", + "sortImports": false, + "sortPackageJson": false, + "ignorePatterns": [ + "**/dist/**", + "**/build/**", + "**/.nx/**", + "**/node_modules/**", + "**/coverage/**", + "**/.verdaccio/**", + "**/.vscode/**", + "**/.trae/**", + "**/.husky/**", + "**/public/**", + "**/storybook-static/**", + "**/migrations.json", + "**/pnpm-lock.yaml", + "**/tsconfig.tsbuildinfo", + "**/sqlite.db", + "**/docker-compose.yml", + "**/routeTree.gen.ts", + "**/*.{less,vue,svelte,astro,yaml,yml,toml,graphql,gql,eta}" + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index b8ab8c6d..2c2eb6d9 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,7 +2,6 @@ "recommendations": [ "nrwl.angular-console", "effectful-tech.effect-vscode", - "oxc.oxc-vscode", - "dprint.dprint" + "oxc.oxc-vscode" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 17160faa..a6c475a3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,16 +4,16 @@ "PATH": "/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:${env:PATH}" }, "[javascript]": { - "editor.defaultFormatter": "dprint.dprint" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[typescript]": { - "editor.defaultFormatter": "dprint.dprint" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[json]": { - "editor.defaultFormatter": "dprint.dprint" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[jsonc]": { - "editor.defaultFormatter": "dprint.dprint" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "editor.codeActionsOnSave": { "quickfix.oxc": "explicit", @@ -21,13 +21,10 @@ "source.fixAll.oxc": "explicit" }, "[javascriptreact]": { - "editor.defaultFormatter": "dprint.dprint" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[typescriptreact]": { - "editor.defaultFormatter": "dprint.dprint" - }, - "[css]": { - "editor.defaultFormatter": "dprint.dprint" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "editor.formatOnSave": true, "files.exclude": { @@ -56,12 +53,12 @@ "files.readonlyInclude": { "**/routeTree.gen.ts": true }, - "editor.defaultFormatter": "dprint.dprint", - "[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": { - "editor.defaultFormatter": "dprint.dprint" + "editor.defaultFormatter": "oxc.oxc-vscode", + "[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][markdown][mdx]": { + "editor.defaultFormatter": "oxc.oxc-vscode" }, "editor.formatOnPaste": true, "emmet.showExpandedAbbreviation": "never", - "typescript.native-preview.tsdk": "node_modules/@typescript/native-preview", + "typescript.native-preview.tsdk": "node_modules/@typescript/native", "js/ts.experimental.useTsgo": true -} \ No newline at end of file +} diff --git a/AGENTS.md b/AGENTS.md index 2ffeb067..7a1fbb54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,14 +24,16 @@ ## Effect-TS Pattern Discovery -This project uses effect-smol as the canonical source for Effect patterns. +This project uses the `main` branch of [Effect-TS/effect](https://github.com/Effect-TS/effect.git) as the canonical Effect v4 source. Effect v3 is maintained on the `v3` branch of that same repository. -| Skill | Description | Location | -| -------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------- | -| `effect-context-manager` | Setup & sync Effect v4 reference | [.agent/skills/effect-context-manager](.agent/skills/effect-context-manager/SKILL.md) | -| `effect-pattern-discovery` | Effect-TS patterns from effect-smol | [.agent/skills/effect-pattern-discovery](.agent/skills/effect-pattern-discovery/SKILL.md) | +| Skill | Description | Location | +| -------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------- | +| `effect-context-manager` | Setup & sync local reference clones | [.agent/skills/effect-context-manager](.agent/skills/effect-context-manager/SKILL.md) | +| `effect-pattern-discovery` | Effect-TS patterns from Effect v4 source | [.agent/skills/effect-pattern-discovery](.agent/skills/effect-pattern-discovery/SKILL.md) | -**Reference Directory**: `.effect-reference/` contains the effect-smol source code mounted as a git worktree. +**Effect Reference**: `.effect-reference/effect/` is an ignored, standalone, depth-1 clone of `https://github.com/Effect-TS/effect.git` on `main`. + +**Alchemy Reference**: `.effect-reference/alchemy/` is an ignored, standalone, depth-1 clone of [`alchemy-run/alchemy`](https://github.com/alchemy-run/alchemy.git) on `main`, and is the canonical Effect-based Alchemy next/alpha reference. [`alchemy-run/alchemy-async`](https://github.com/alchemy-run/alchemy-async) is the former async implementation. ## Hatchet + Effect Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 69df628c..9c4d6f3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,19 +4,74 @@ All notable changes to this project will be documented in this file. This changelog summarizes releases for the following packages: -- @@effectify/prisma -- @@effectify/react-query -- @@effectify/react-router-better-auth -- @@effectify/node-better-auth -- @@effectify/react-router -- @@effectify/react-remix -- @@effectify/solid-query -- @@effectify/hatchet +- @effectify/prisma +- @effectify/react-query +- @effectify/react-router-better-auth +- @effectify/node-better-auth +- @effectify/react-router +- @effectify/solid-query +- @effectify/hatchet -## @@effectify/prisma +## @effectify/hatchet + +## 0.1.0-alpha.5 (2026-07-12) + +## @effectify/node-better-auth + +## 0.5.12-alpha.1 (2026-07-12) + +## @effectify/prisma + +## 1.1.13-alpha.1 (2026-07-12) + +## @effectify/react-query + +## 1.0.0-alpha.7 (2026-07-12) + +## @effectify/react-router + +## 0.5.11-alpha.1 (2026-07-12) + +## @effectify/react-router-better-auth + +## 0.5.12-alpha.1 (2026-07-12) + +## @effectify/solid-query + +## 0.5.12-alpha.1 (2026-07-12) + +## @effectify/hatchet + +## 0.1.0-alpha.4 (2026-07-11) + +## @effectify/node-better-auth + +## 0.5.12-alpha.0 (2026-07-11) + +## @effectify/prisma + +## 1.1.13-alpha.0 (2026-07-11) + +## @effectify/react-query + +## 1.0.0-alpha.6 (2026-07-11) + +## @effectify/react-router + +## 0.5.11-alpha.0 (2026-07-11) + +## @effectify/react-router-better-auth + +## 0.5.12-alpha.0 (2026-07-11) + +## @effectify/solid-query + +## 0.5.12-alpha.0 (2026-07-11) + +## @effectify/prisma ## 2.0.0-alpha.3 (2026-04-27) -## @@effectify/hatchet +## @effectify/hatchet ## 0.1.0-alpha.3 (2026-04-27) diff --git a/README.md b/README.md index 827ae3df..1c22a615 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,58 @@ # Effectify -[![Alpha Release](https://img.shields.io/badge/alpha-v4%20alpha-blue)](https://www.npmjs.com/search?q=%40effectify) +[![Alpha Release](https://img.shields.io/badge/channel-alpha-blue)](https://www.npmjs.com/search?q=%40effectify) [![Documentation](https://img.shields.io/badge/docs-effectify.dev-00C853)](https://devx-op.github.io/effectify/) -Monorepo of utilities for integrating [Effect](https://effect.website/) with different frameworks and libraries. +Effectify provides Effect integrations for React, Solid, authentication, Prisma, and Hatchet. -> **🚀 Effect v4 Alpha Support**: We are currently migrating packages to support Effect v4 beta. Alpha versions are available on npm with the `@alpha` tag. +> **Effect v4 RC:** The current workspace targets the Effect v4 release candidate (`effect@4.0.0-rc.111`). Prerelease packages are published on explicit npm tags and never replace the stable default by accident. + +## Choose a release channel + +| Channel | Trigger | npm tag | Use it for | +| ------- | ---------------------- | ------------------ | --------------------------------------- | +| Alpha | Push to `dev` | `alpha` | Earliest integration builds | +| Beta | Push to `master` | `beta` | Master-qualified prereleases | +| Stable | Manual stable workflow | default (`latest`) | Explicitly selected production releases | + +Install an explicit channel; do not rely on npm's default tag for prereleases. ## Packages -| Package | Version | Documentation | Description | -| -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | -| [@effectify/solid-query](https://www.npmjs.com/package/@effectify/solid-query) | [![npm version](https://img.shields.io/npm/v/@effectify/solid-query.svg)](https://www.npmjs.com/package/@effectify/solid-query) | [Docs](./packages/solid/query/README.md) | Integration of Effect with TanStack Query for Solid.js | -| [@effectify/react-query](https://www.npmjs.com/package/@effectify/react-query) | [![npm version](https://img.shields.io/npm/v/@effectify/react-query.svg)](https://www.npmjs.com/package/@effectify/react-query) | [Docs](./packages/react/query/README.md) | Integration of Effect with TanStack Query for React | -| [@effectify/react-router](https://www.npmjs.com/package/@effectify/react-router) | [![npm version](https://img.shields.io/npm/v/@effectify/react-router.svg)](https://www.npmjs.com/package/@effectify/react-router) | [Docs](./packages/react/router/README.md) | Integration of React Router with Effect for React applications | -| [@effectify/react-remix](https://www.npmjs.com/package/@effectify/react-remix) | [![npm version](https://img.shields.io/npm/v/@effectify/react-remix.svg)](https://www.npmjs.com/package/@effectify/react-remix) | [Docs](./packages/react/remix/README.md) | Integration of Remix with Effect for React applications | -| [@effectify/node-better-auth](https://www.npmjs.com/package/@effectify/node-better-auth) | [![npm version](https://img.shields.io/npm/v/@effectify/node-better-auth.svg)](https://www.npmjs.com/package/@effectify/node-better-auth) | [Docs](./packages/node/better-auth/README.md) | Integration of better-auth with Effect for Node.js applications | -| [@effectify/react-router-better-auth](https://www.npmjs.com/package/@effectify/react-router-better-auth) | [![npm version](https://img.shields.io/npm/v/@effectify/react-router-better-auth.svg)](https://www.npmjs.com/package/@effectify/react-router-better-auth) | [Docs](./packages/react/router-better-auth/) | Integration of React Router + better-auth with Effect for React applications | -| [@effectify/prisma](https://www.npmjs.com/package/@effectify/prisma) | [![npm version](https://img.shields.io/npm/v/@effectify/prisma.svg)](https://www.npmjs.com/package/@effectify/prisma) | [Docs](./packages/prisma/README.md) | Prisma generator and runtime utilities for Effect | -| [@effectify/solid-effect-atom](https://www.npmjs.com/package/@effectify/solid-effect-atom) | [![npm version](https://img.shields.io/npm/v/@effectify/solid-effect-atom.svg)](https://www.npmjs.com/package/@effectify/solid-effect-atom) | [Docs](./packages/solid/effect-atom/README.md) | Reactive toolkit for Effect with SolidJS | +| Package | Documentation | Scope | +| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------ | +| [`@effectify/react-router`](https://www.npmjs.com/package/@effectify/react-router) | [Docs](./packages/react/router/README.md) | Maintained React Router 8 integration | +| [`@effectify/react-query`](https://www.npmjs.com/package/@effectify/react-query) | [Docs](./packages/react/query/README.md) | TanStack Query integration for React | +| [`@effectify/node-better-auth`](https://www.npmjs.com/package/@effectify/node-better-auth) | [Docs](./packages/node/better-auth/README.md) | better-auth integration for Node.js | +| [`@effectify/solid-query`](https://www.npmjs.com/package/@effectify/solid-query) | [Docs](./packages/solid/query/README.md) | TanStack Query integration for Solid | +| [`@effectify/react-router-better-auth`](https://www.npmjs.com/package/@effectify/react-router-better-auth) | [Usage reference](./packages/react/router-better-auth/tests/auth-guard.test.ts) | React Router 8 and better-auth integration | +| [`@effectify/prisma`](https://www.npmjs.com/package/@effectify/prisma) | [Docs](./packages/prisma/README.md) | Prisma generator and runtime utilities | +| [`@effectify/hatchet`](https://www.npmjs.com/package/@effectify/hatchet) | [Package](./packages/hatchet/) | Hatchet workflow integration | -## Alpha Installation (Effect v4) +The supported router surface is React Router 8 only. The Solid example uses Effect v4's `Atom` and `AtomRef` modules with the official [`@effect/atom-solid`](https://www.npmjs.com/package/@effect/atom-solid) bindings. -We are actively migrating packages to support Effect v4 beta. You can install alpha versions using the `@alpha` npm tag: +## Install alpha packages + +Every Nx release package is available through the explicit alpha channel when an alpha version has been published: ```bash -# npm -npm install @effectify/react-query@alpha -npm install @effectify/solid-query@alpha npm install @effectify/react-router@alpha +npm install @effectify/react-query@alpha npm install @effectify/node-better-auth@alpha -npm install @effectify/react-remix@alpha +npm install @effectify/solid-query@alpha +npm install @effectify/react-router-better-auth@alpha npm install @effectify/prisma@alpha - -# pnpm -pnpm add @effectify/react-query@alpha - -# yarn -yarn add @effectify/react-query@alpha +npm install @effectify/hatchet@alpha ``` -### Migration Status - -| Package | v4 Alpha Status | Stable Version | -| --------------------------- | --------------- | -------------- | -| @effectify/react-query | 🚧 In Progress | ✅ v3 | -| @effectify/solid-query | 🚧 In Progress | ✅ v3 | -| @effectify/react-router | 🚧 In Progress | ✅ v3 | -| @effectify/node-better-auth | 🚧 In Progress | ✅ v3 | -| @effectify/react-remix | 🚧 In Progress | ✅ v3 | -| @effectify/prisma | 🚧 In Progress | ✅ v3 | - -**Legend**: ✅ Available | 🚧 Migrating | ⏳ Pending - -### Effect v3 vs v4 - -- **Stable releases** (v3.x) continue to work with Effect v3.19.x -- **Alpha releases** (v4.x) require Effect v4 beta -- Both versions maintain the same API where possible - -For migration details, see the [Effect v4 Migration Guide](https://effect.website/docs/migration/v4). +Use the same package names with `pnpm add` or `yarn add` if those are your package managers. Alpha and beta releases require the current Effect v4 RC. Stable compatibility is documented by each package release. ## Development ### Requirements -- [pnpm](https://pnpm.io/) -- [Node.js](https://nodejs.org/) +- Node.js 24.19.0 +- pnpm 10.14.0 ### Commands @@ -74,23 +60,27 @@ For migration details, see the [Effect v4 Migration Guide](https://effect.websit # Install dependencies pnpm install -# Run example application -pnpm nx dev tanstack-solid-app +# Run the maintained Solid example +pnpm nx dev @effectify/solid-example -# Build all packages +# Build affected packages pnpm nx affected -t build -# Clean project -pnpm clean -``` +# Check or apply pinned formatting to changed files +pnpm format:check +pnpm format -### Release Management +# Verify React Router 8 consolidation and readiness +pnpm nx run @effectify/react-router-example:consolidation:verify +pnpm nx run @effectify/react-router-example:migration:manifest +pnpm nx run @effectify/react-router-example:migration:verify +``` -To skip a release for documentation updates or other non-release changes, include `[skip release]` in your commit message. +See [`.github/SETUP.md`](./.github/SETUP.md) for exact CI triggers, release behavior, and stable recovery. ## Credits & Inspiration -This project was inspired by the excellent educational content from [Lucas Barake](https://www.youtube.com/@lucas-barake), particularly his [video on Effect and TanStack Query](https://www.youtube.com/watch?v=zl4w3BQAoJM&t=1011s) which provides great insights into these technologies. +This project was inspired by the educational content from [Lucas Barake](https://www.youtube.com/@lucas-barake), particularly his [Effect and TanStack Query video](https://www.youtube.com/watch?v=zl4w3BQAoJM&t=1011s). ## License diff --git a/apps/docs/astro.config.ts b/apps/docs/astro.config.ts index efc45298..18c62f84 100644 --- a/apps/docs/astro.config.ts +++ b/apps/docs/astro.config.ts @@ -30,9 +30,7 @@ export default defineConfig({ }, { label: "Reference", - autogenerate: { - directory: "react/reference", - }, + items: [{ autogenerate: { directory: "react/reference" } }], }, ], }, @@ -52,9 +50,7 @@ export default defineConfig({ }, { label: "Reference", - autogenerate: { - directory: "solid/reference", - }, + items: [{ autogenerate: { directory: "solid/reference" } }], }, ], }, @@ -74,9 +70,7 @@ export default defineConfig({ }, { label: "Reference", - autogenerate: { - directory: "backend/reference", - }, + items: [{ autogenerate: { directory: "backend/reference" } }], }, ], }, @@ -96,9 +90,7 @@ export default defineConfig({ }, { label: "Reference", - autogenerate: { - directory: "universal/reference", - }, + items: [{ autogenerate: { directory: "universal/reference" } }], }, ], }, diff --git a/apps/docs/package.json b/apps/docs/package.json index 9709ecb4..3162cb90 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -20,7 +20,9 @@ "starlight-sidebar-topics": "catalog:", "starlight-sidebar-topics-dropdown": "catalog:" }, - "devDependencies": {}, + "devDependencies": { + "typescript": "catalog:" + }, "peerDependencies": {}, "optionalDependencies": {} } diff --git a/apps/docs/src/content/docs/es/solid/packages/solid-effect-atom.md b/apps/docs/src/content/docs/es/solid/packages/solid-effect-atom.md index 4d1efade..ec3c1ec5 100644 --- a/apps/docs/src/content/docs/es/solid/packages/solid-effect-atom.md +++ b/apps/docs/src/content/docs/es/solid/packages/solid-effect-atom.md @@ -1,25 +1,25 @@ --- -title: "@effectify/solid-effect-atom" -description: Herramientas reactivas para Effect con SolidJS +title: Atom de Effect v4 con SolidJS +description: Usa Atom y AtomRef de Effect v4 con los bindings oficiales para SolidJS sidebar: - label: "@effectify/solid-effect-atom" + label: Effect Atom para SolidJS order: 1 --- -Bindings de SolidJS para la primitiva `Atom` de Effect. Esta librería permite utilizar el estado reactivo de Effect (`Atom`) dentro de componentes SolidJS de manera eficiente y segura. +El paquete oficial `@effect/atom-solid` conecta los módulos principales `Atom` y `AtomRef` de Effect v4 con SolidJS. Proporciona accessors reactivos, setters, suscripciones y alcance de registros sin un paquete adaptador de Effectify. ## Instalación ```bash -npm install @effectify/solid-effect-atom @effect-atom/atom effect solid-js +npm install effect @effect/atom-solid solid-js ``` ## Configuración -Para usar los átomos, debes envolver tu aplicación (o la parte que los use) con `RegistryProvider`. Esto provee el contexto necesario para el registro de átomos. +Usa `RegistryProvider` cuando el estado de los átomos deba limitarse a un subárbol de Solid. Sin un provider, los hooks usan el registro independiente predeterminado. ```tsx -import { RegistryProvider } from "@effectify/solid-effect-atom" +import { RegistryProvider } from "@effect/atom-solid" function App() { return ( @@ -30,42 +30,42 @@ function App() { } ``` -## Uso Básico +## Uso básico -### Crear un Átomo +### Crear un átomo -Utiliza `Atom.make` del paquete `@effect-atom/atom`. +Importa `Atom` desde los módulos de reactividad de Effect v4. ```ts -import * as Atom from "@effect-atom/atom/Atom" +import * as Atom from "effect/unstable/reactivity/Atom" const counterAtom = Atom.make(0) ``` ### useAtom -Hook para leer y escribir un átomo. Similar a `createSignal` de Solid. +`useAtom` lee y escribe un átomo, de forma similar a `createSignal` de Solid. Los hooks reciben una función para respetar el modelo de propiedad reactiva de Solid. ```tsx -import { useAtom } from "@effectify/solid-effect-atom" +import { useAtom } from "@effect/atom-solid" function Counter() { - const [count, setCount] = useAtom(counterAtom) + const [count, setCount] = useAtom(() => counterAtom) - return + return } ``` ### useAtomValue -Hook para solo leer el valor de un átomo. Puedes pasar una función selectora para transformar el valor (computado). +Usa `useAtomValue` cuando un componente solo necesite un accessor reactivo. Un selector opcional permite derivar un valor. ```tsx -import { useAtomValue } from "@effectify/solid-effect-atom" +import { useAtomValue } from "@effect/atom-solid" function Display() { - const count = useAtomValue(counterAtom) - const doubled = useAtomValue(counterAtom, (n) => n * 2) + const count = useAtomValue(() => counterAtom) + const doubled = useAtomValue(() => counterAtom, (value) => value * 2) return (
@@ -76,87 +76,59 @@ function Display() { } ``` -## Uso Avanzado +## Uso avanzado -### useAtomSet - -Útil cuando solo necesitas actualizar el átomo sin suscribirte a sus cambios. +### Escribir sin suscribirse ```tsx -import { useAtomSet } from "@effectify/solid-effect-atom" +import { useAtomSet } from "@effect/atom-solid" function ResetButton() { - const setCount = useAtomSet(counterAtom) + const setCount = useAtomSet(() => counterAtom) return } ``` -### useAtomSubscribe +### Suscribirse o mantener un átomo montado -Se suscribe a los cambios del átomo manualmente. Útil para efectos secundarios (logging, analytics, etc.). +`useAtomSubscribe` ejecuta un callback ante los cambios, mientras que `useAtomMount` mantiene un átomo montado durante la vida del owner actual de Solid. ```tsx -import { useAtomSubscribe } from "@effectify/solid-effect-atom" +import { useAtomMount, useAtomSubscribe } from "@effect/atom-solid" -function Logger() { - useAtomSubscribe(counterAtom, (val) => { - console.log("Counter changed:", val) +function Observer() { + useAtomMount(() => counterAtom) + useAtomSubscribe(() => counterAtom, (value) => { + console.log("Counter changed:", value) }) return null } ``` -### useAtomMount - -Monta manualmente un átomo. Útil si quieres mantener un átomo vivo en el registro sin leer su valor. - -```tsx -import { useAtomMount } from "@effectify/solid-effect-atom" - -function Keeper() { - useAtomMount(counterAtom) - return null -} -``` - -### useAtomInitialValues - -Útil para SSR o inicializar estado desde props. +### Inicializar y refrescar valores ```tsx -import { useAtomInitialValues } from "@effectify/solid-effect-atom" +import { useAtomInitialValues, useAtomRefresh } from "@effect/atom-solid" -function Initializer() { +function Controls() { useAtomInitialValues([[counterAtom, 100]]) - return null -} -``` - -### useAtomRefresh - -Fuerza la reevaluación o reinicio de un átomo. - -```tsx -import { useAtomRefresh } from "@effectify/solid-effect-atom" - -function Refresher() { - const refresh = useAtomRefresh(counterAtom) - return + const refresh = useAtomRefresh(() => counterAtom) + return } ``` -### useAtomRef +### Trabajar con AtomRef -Para trabajar con referencias mutables (`AtomRef`). +`AtomRef` forma parte del núcleo de Effect v4. `useAtomRef` conecta un accessor de Solid directamente con una referencia. ```tsx -import * as AtomRef from "@effect-atom/atom/AtomRef" -import { useAtomRef } from "@effectify/solid-effect-atom" +import { useAtomRef } from "@effect/atom-solid" +import * as AtomRef from "effect/unstable/reactivity/AtomRef" const configRef = AtomRef.make({ theme: "dark" }) function Config() { - const config = useAtomRef(configRef) + const config = useAtomRef(() => configRef) return ( + return } ``` ### useAtomValue -Hook to only read an atom's value. You can pass a selector function to transform the value (computed). +Use `useAtomValue` when a component only needs a reactive accessor. An optional selector derives a value. ```tsx -import { useAtomValue } from "@effectify/solid-effect-atom" +import { useAtomValue } from "@effect/atom-solid" function Display() { - const count = useAtomValue(counterAtom) - const doubled = useAtomValue(counterAtom, (n) => n * 2) + const count = useAtomValue(() => counterAtom) + const doubled = useAtomValue(() => counterAtom, (value) => value * 2) return (
@@ -78,85 +78,57 @@ function Display() { ## Advanced Usage -### useAtomSet - -Useful when you only need to update the atom without subscribing to its changes. +### Write without subscribing ```tsx -import { useAtomSet } from "@effectify/solid-effect-atom" +import { useAtomSet } from "@effect/atom-solid" function ResetButton() { - const setCount = useAtomSet(counterAtom) + const setCount = useAtomSet(() => counterAtom) return } ``` -### useAtomSubscribe +### Subscribe or keep an atom mounted -Subscribes to atom changes manually. Useful for side effects (logging, analytics, etc.). +`useAtomSubscribe` runs a callback for changes, while `useAtomMount` keeps an atom mounted for the lifetime of the current Solid owner. ```tsx -import { useAtomSubscribe } from "@effectify/solid-effect-atom" +import { useAtomMount, useAtomSubscribe } from "@effect/atom-solid" -function Logger() { - useAtomSubscribe(counterAtom, (val) => { - console.log("Counter changed:", val) +function Observer() { + useAtomMount(() => counterAtom) + useAtomSubscribe(() => counterAtom, (value) => { + console.log("Counter changed:", value) }) return null } ``` -### useAtomMount - -Manually mounts an atom. Useful if you want to keep an atom alive in the registry without rendering its value. +### Seed and refresh values ```tsx -import { useAtomMount } from "@effectify/solid-effect-atom" - -function Keeper() { - useAtomMount(counterAtom) - return null -} -``` - -### useAtomInitialValues +import { useAtomInitialValues, useAtomRefresh } from "@effect/atom-solid" -Useful for SSR or initializing state from props. - -```tsx -import { useAtomInitialValues } from "@effectify/solid-effect-atom" - -function Initializer() { +function Controls() { useAtomInitialValues([[counterAtom, 100]]) - return null + const refresh = useAtomRefresh(() => counterAtom) + return } ``` -### useAtomRefresh +### Work with AtomRef -Forces an atom to re-evaluate or reset. +`AtomRef` is part of Effect v4 core. `useAtomRef` subscribes a Solid accessor directly to a ref. ```tsx -import { useAtomRefresh } from "@effectify/solid-effect-atom" - -function Refresher() { - const refresh = useAtomRefresh(counterAtom) - return -} -``` - -### useAtomRef - -For working with mutable references (`AtomRef`). - -```tsx -import * as AtomRef from "@effect-atom/atom/AtomRef" -import { useAtomRef } from "@effectify/solid-effect-atom" +import { useAtomRef } from "@effect/atom-solid" +import * as AtomRef from "effect/unstable/reactivity/AtomRef" const configRef = AtomRef.make({ theme: "dark" }) function Config() { - const config = useAtomRef(configRef) + const config = useAtomRef(() => configRef) return ( - - -
-
- -
- - Templates author this route now; Html.el stays at the full document boundary only. - - - Reactive cue: templates drive the count text plus Loom-native attr/class/style bindings from Loom state. - - - Dev caveat: in plain Vite dev the browser uses mount(...) to fill the empty root when no payload is present. That fallback is honest DX, not fake full SSR. - -
- - ` - ), -) - -export default CounterRoute diff --git a/apps/loom-example-app/src/routes/todo-route-state.ts b/apps/loom-example-app/src/routes/todo-route-state.ts deleted file mode 100644 index 084aa427..00000000 --- a/apps/loom-example-app/src/routes/todo-route-state.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Atom, AtomRegistry } from "effect/unstable/reactivity" -import { cloneInitialTodos, type TodoItem } from "../todo-service.js" - -export type TodoLoaderStatus = "idle" | "loading" | "loaded" | "revalidating" | "failure" - -export type TodoActionStatus = "idle" | "submitting" | "success" | "invalid-input" | "failure" - -export const todoRegistry = AtomRegistry.make() - -export const todoDraftAtom = Atom.make("") -export const todoItemsAtom = Atom.make>([]) -export const todoLoaderStatusAtom = Atom.make("idle") -export const todoActionStatusAtom = Atom.make("idle") -export const todoFeedbackAtom = Atom.make(undefined) - -export const setTodoItems = (items: ReadonlyArray): void => { - todoRegistry.set(todoItemsAtom, [...items]) -} - -export const setTodoLoaderStatus = (status: TodoLoaderStatus): void => { - todoRegistry.set(todoLoaderStatusAtom, status) -} - -export const setTodoActionStatus = (status: TodoActionStatus): void => { - todoRegistry.set(todoActionStatusAtom, status) -} - -export const setTodoFeedback = (feedback: string | undefined): void => { - todoRegistry.set(todoFeedbackAtom, feedback) -} - -export const resetTodoRouteViewState = (): void => { - todoRegistry.set(todoDraftAtom, "") - setTodoItems(cloneInitialTodos()) - setTodoLoaderStatus("idle") - setTodoActionStatus("idle") - setTodoFeedback(undefined) -} diff --git a/apps/loom-example-app/src/routes/todo-route.ts b/apps/loom-example-app/src/routes/todo-route.ts deleted file mode 100644 index 2831c2e9..00000000 --- a/apps/loom-example-app/src/routes/todo-route.ts +++ /dev/null @@ -1,255 +0,0 @@ -import * as Effect from "effect/Effect" -import { View } from "@effectify/loom" -import { Route, RouteModule, Router, Runtime } from "@effectify/loom-router" -import { todoRegistry } from "./todo-route-state.js" -import { - resetTodoRouteViewState, - setTodoActionStatus, - setTodoFeedback, - setTodoItems, - setTodoLoaderStatus, -} from "./todo-route-state.js" -import { - TodoCommandResultSchema, - TodoCommandSchema, - TodoItemsSchema, - TodoRouteErrorSchema, - type TodoRouteServices, -} from "./todo-route/todo-route-shared.js" -import { TodoRoute } from "./todo-route/todo-route-component.js" -import { makeTodoService, type TodoItem } from "../todo-service.js" - -export const todoRouteId = "todo" -export const todoRoutePath = "/todos" -export const todoRouteTitle = "Todo app" - -export default Object.assign(TodoRoute, { registry: todoRegistry }) - -const todoLoaderOptions = { - output: TodoItemsSchema, - services: Route.services(), -} as const - -export const loader = Route.loader({ - ...todoLoaderOptions, - load: ({ services }) => services.todoService.list(), -}) - -const todoActionOptions = { - input: TodoCommandSchema, - output: TodoCommandResultSchema, - error: TodoRouteErrorSchema, - services: Route.services(), -} as const - -export const action = Route.action({ - ...todoActionOptions, - handle: ({ input, services }) => services.todoService.dispatch(input), -}) - -const todoRouteModule = { - action, - default: () => View.use(TodoRoute), - loader, -} - -export const todoPageRoute = RouteModule.compile({ - identifier: todoRouteId, - module: todoRouteModule, - path: todoRoutePath, -}) - -type TodoLoaderState = Runtime.LoaderState -type TodoActionState = Runtime.ActionState - -type TodoRuntimeServices = Readonly<{ - todoService: TodoRouteServices["todoService"] -}> - -export interface TodoRouteRuntime { - readonly load: (input?: string | URL) => Promise - readonly reset: () => void - readonly submit: ( - options: { readonly input?: string | URL; readonly submission: Runtime.Submission }, - ) => Promise<{ - readonly action: TodoActionState - readonly loader?: TodoLoaderState - }> -} - -export interface TodoRouteSubmissionResult { - readonly action: { - readonly _tag: string - } - readonly loader?: unknown -} - -const todoServices = (): TodoRuntimeServices => ({ - todoService: makeTodoService(), -}) - -const todoRuntimeRouter = Router.make({ - routes: [todoPageRoute], -}) - -type TodoResolvedRoute = - & Router.ResolveSuccess - & { - readonly route: typeof todoPageRoute - } - -const isTodoResolvedRoute = (value: Router.ResolveResult): value is TodoResolvedRoute => - Router.isResolveSuccess(value) && value.route.identifier === todoRouteId - -const resolveTodoRoute = (input: string | URL = todoRoutePath): TodoResolvedRoute => { - const resolved = Router.resolve(todoRuntimeRouter, input) - - if (!isTodoResolvedRoute(resolved)) { - throw new Error(`Expected '${todoRoutePath}' to resolve successfully for the Loom todo runtime`) - } - - return resolved -} - -const staleTodoData = (state: TodoLoaderState | undefined): ReadonlyArray | undefined => { - if (state === undefined) { - return undefined - } - - switch (state._tag) { - case "success": - case "revalidating": - return state.data - case "failure": - return state.data - default: - return undefined - } -} - -const syncTodoLoaderState = (state: TodoLoaderState): void => { - switch (state._tag) { - case "success": - setTodoItems(state.data) - setTodoLoaderStatus("loaded") - setTodoFeedback(undefined) - return - case "revalidating": - setTodoItems(state.data) - setTodoLoaderStatus("revalidating") - return - case "failure": - setTodoItems(state.data ?? []) - setTodoLoaderStatus("failure") - setTodoFeedback(state.error.message) - return - case "loading": - setTodoLoaderStatus("loading") - return - case "idle": - setTodoLoaderStatus("idle") - } -} - -const syncTodoActionState = (state: TodoActionState): void => { - switch (state._tag) { - case "success": - setTodoActionStatus("success") - setTodoFeedback(`Action '${state.result.intent}' completed and revalidated.`) - return - case "failure": - setTodoActionStatus("failure") - setTodoFeedback(state.error.message) - return - case "invalid-input": - setTodoActionStatus("invalid-input") - setTodoFeedback(state.issues[0].message) - return - case "submitting": - setTodoActionStatus("submitting") - return - case "idle": - setTodoActionStatus("idle") - setTodoFeedback(undefined) - } -} - -export const createTodoRouteRuntime = (services: TodoRuntimeServices = todoServices()): TodoRouteRuntime => { - let latestLoaderState: TodoLoaderState | undefined - - return { - load: async (input = todoRoutePath) => { - const loaded = await Runtime.load({ - resolved: resolveTodoRoute(input), - services, - }) - - latestLoaderState = loaded - - return loaded - }, - reset: () => { - Effect.runSync(services.todoService.reset()) - latestLoaderState = undefined - }, - submit: async ({ input = todoRoutePath, submission }) => { - const resolved = resolveTodoRoute(input) - const action = await Runtime.submit({ - resolved, - services, - submission, - }) - - if (action._tag !== "success") { - return { action } - } - - const previous = staleTodoData(latestLoaderState) - const loader = previous === undefined - ? await Runtime.load({ resolved, services }) - : await Runtime.revalidate({ previous, resolved, services }) - - latestLoaderState = loader - - return { - action, - loader, - } - }, - } -} - -const todoRouteRuntime = createTodoRouteRuntime() - -export const loadTodoRouteState = async (input: string | URL = todoRoutePath): Promise => { - setTodoLoaderStatus("loading") - const loaded = await todoRouteRuntime.load(input) - - syncTodoLoaderState(loaded) - return loaded -} - -export const prepareTodoRoute = async (input: URL): Promise => { - if (input.pathname === todoRoutePath) { - await loadTodoRouteState(input) - } -} - -export const submitTodoRoute = async (submission: Runtime.Submission): Promise => { - setTodoActionStatus("submitting") - - const result = await todoRouteRuntime.submit({ submission }) - - syncTodoActionState(result.action) - - if (result.loader !== undefined) { - syncTodoLoaderState(result.loader) - } - - return result -} - -export const resetTodoRouteExampleState = (): void => { - todoRouteRuntime.reset() - resetTodoRouteViewState() -} diff --git a/apps/loom-example-app/src/routes/todo-route/todo-composer.ts b/apps/loom-example-app/src/routes/todo-route/todo-composer.ts deleted file mode 100644 index d994ca37..00000000 --- a/apps/loom-example-app/src/routes/todo-route/todo-composer.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Atom } from "effect/unstable/reactivity" -import { Component, html, View } from "@effectify/loom" -import { todoActionStatusAtom, todoDraftAtom } from "../todo-route-state.js" -import { submitTodoRoute } from "../todo-route.js" -import { TodoPanel } from "./todo-route-shared.js" - -const readTodoTitleInput = (form: HTMLFormElement): string | undefined => { - const titleInput = form.elements.namedItem("title") - return titleInput instanceof HTMLInputElement ? titleInput.value : undefined -} - -export const TodoComposer = Component.make().pipe( - Component.state({ - actionStatus: todoActionStatusAtom, - draft: todoDraftAtom, - additions: () => Atom.make(0), - }), - Component.actions(({ model }) => ({ - submitDraft: async (titleInput?: string): Promise => { - const title = titleInput ?? model.draft.get() - const result = await submitTodoRoute({ - intent: "create", - title, - }) - - if (result.action._tag === "success") { - model.draft.set("") - model.additions.update((value: number) => value + 1) - } - }, - syncDraft: (value: string): void => { - model.draft.set(value) - }, - })), - Component.view(({ state, actions }) => - View.use(TodoPanel, [ - html` -

Composer

- - The Add button now submits normalized action input through the Loom runtime before the loader revalidates the list. - - `, - html` -
-
{ - event.preventDefault() - - if (currentTarget instanceof HTMLFormElement) { - void actions.submitDraft(readTodoTitleInput(currentTarget)) - } - }} - > - state.actionStatus() === "submitting"} - web:value=${() => state.draft()} - web:input=${({ currentTarget }) => { - if (currentTarget instanceof HTMLInputElement) { - actions.syncDraft(currentTarget.value) - } - }} - /> - -
-
- `, - html` -
- Every successful submit triggers a self-revalidation through the route loader. - - ${() => `Added from this mounted composer: ${state.additions()}`} - -
- `, - ]) - ), -) diff --git a/apps/loom-example-app/src/routes/todo-route/todo-hero.ts b/apps/loom-example-app/src/routes/todo-route/todo-hero.ts deleted file mode 100644 index a6c556e3..00000000 --- a/apps/loom-example-app/src/routes/todo-route/todo-hero.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Component, html } from "@effectify/loom" -import { counterRoutePath } from "../counter-route.js" -import { todoDraftAtom, todoItemsAtom, todoLoaderStatusAtom } from "../todo-route-state.js" - -export const TodoHero = Component.make().pipe( - Component.state({ - draft: todoDraftAtom, - todos: todoItemsAtom, - loaderStatus: todoLoaderStatusAtom, - }), - Component.view(({ state }) => - html` -
-
- Example app only -

Loom vNext todo app

- - This route now reads through a loader and writes through an action runtime, while the UI is authored with Loom templates plus View.of and View.use composition. - -
- -
-
- Loaded todos - ${() => `${state.todos().length} tracked todos`} -
-
- Loader status - ${() => state.loaderStatus()} -
-
- Draft sync - - ${() => state.draft().trim().length > 0 ? "Composer is holding input" : "Composer is empty"} - -
- Back to counter -
-
- ` - ), -) diff --git a/apps/loom-example-app/src/routes/todo-route/todo-insights.ts b/apps/loom-example-app/src/routes/todo-route/todo-insights.ts deleted file mode 100644 index 52c7acba..00000000 --- a/apps/loom-example-app/src/routes/todo-route/todo-insights.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Component, html, View } from "@effectify/loom" -import { todoActionStatusAtom, todoFeedbackAtom, todoItemsAtom } from "../todo-route-state.js" -import { completedTodoCount, remainingTodoCount, TodoPanel } from "./todo-route-shared.js" - -export const TodoInsights = Component.make().pipe( - Component.state({ - actionStatus: todoActionStatusAtom, - feedback: todoFeedbackAtom, - todos: todoItemsAtom, - }), - Component.view(({ state }) => - View.use( - TodoPanel, - html` -

Runtime snapshot

- - The route loader owns the initial list, and every button funnels through the route action plus self-revalidation. - - -
-
- Open - ${() => - `${remainingTodoCount(state.todos())}`} -
-
- Completed - - ${() => `${completedTodoCount(state.todos())}`} - -
-
- Action status - ${() => state.actionStatus()} -
-
- - ${ - View.if( - () => state.feedback() !== undefined, - html`${() => state.feedback() ?? ""}`, - html``, - ) - } - `, - ) - ), -) diff --git a/apps/loom-example-app/src/routes/todo-route/todo-list.ts b/apps/loom-example-app/src/routes/todo-route/todo-list.ts deleted file mode 100644 index bdd90854..00000000 --- a/apps/loom-example-app/src/routes/todo-route/todo-list.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Component, html, View } from "@effectify/loom" -import { todoActionStatusAtom, todoItemsAtom } from "../todo-route-state.js" -import { submitTodoRoute } from "../todo-route.js" -import { hasCompletedTodos, TodoPanel } from "./todo-route-shared.js" - -export const TodoList = Component.make().pipe( - Component.state({ - actionStatus: todoActionStatusAtom, - todos: todoItemsAtom, - }), - Component.actions(() => ({ - clearCompleted: async (): Promise => { - await submitTodoRoute({ intent: "clear-completed" }) - }, - removeTodo: async (id: number): Promise => { - await submitTodoRoute({ intent: "remove", id: String(id) }) - }, - toggleTodo: async (id: number): Promise => { - await submitTodoRoute({ intent: "toggle", id: String(id) }) - }, - })), - Component.view(({ state, actions }) => - View.use( - TodoPanel, - html` -
-
-

Todo list

- - Secondary buttons also dispatch through the same route action instead of mutating atoms in place. - -
- - -
- - ${ - View.if( - () => state.todos().length === 0, - html`No todos left. Add another one from the composer above.`, - html` -
    - ${ - View.for(() => state.todos(), { - key: (todo) => todo.id, - render: (todo) => - html` -
  • - - -
    - - ${todo.title} - - ${todo.completed ? "Completed task" : "Open task"} -
    - - -
  • - `, - }) - } -
- `, - ) - } - `, - ) - ), -) diff --git a/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts b/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts deleted file mode 100644 index 402e7812..00000000 --- a/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Component, html, View } from "@effectify/loom" -import { TodoComposer } from "./todo-composer.js" -import { TodoHero } from "./todo-hero.js" -import { TodoInsights } from "./todo-insights.js" -import { TodoList } from "./todo-list.js" -import { TodoNotes, TodoPageShell } from "./todo-route-shared.js" - -export const TodoRoute = Component.make().pipe( - Component.view(() => - html` - ${ - View.use( - TodoPageShell, - html` - ${View.of(TodoHero)} -
- ${View.of(TodoInsights)} - ${View.of(TodoComposer)} -
- ${View.of(TodoList)} - ${View.of(TodoNotes)} - `, - ) - } - ` - ), -) diff --git a/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts b/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts deleted file mode 100644 index f7f530d7..00000000 --- a/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as Schema from "effect/Schema" -import { Component, html } from "@effectify/loom" -import { type TodoItem, TodoNotFoundError, type TodoServiceApi } from "../../todo-service.js" - -export type TodoRouteServices = Readonly<{ - todoService: TodoServiceApi -}> - -const TodoIntentSchema = Schema.Union([ - Schema.Literal("create"), - Schema.Literal("toggle"), - Schema.Literal("remove"), - Schema.Literal("clear-completed"), -]) - -const TodoIdSchema = Schema.NumberFromString.check(Schema.isInt()) - -const TodoTitleSchema = Schema.Trim.check(Schema.isNonEmpty()) - -export const TodoItemSchema = Schema.Struct({ - completed: Schema.Boolean, - id: Schema.Number, - title: Schema.String, -}) - -export const TodoItemsSchema = Schema.Array(TodoItemSchema) - -export const TodoCommandSchema = Schema.Union([ - Schema.Struct({ intent: Schema.Literal("create"), title: TodoTitleSchema }), - Schema.Struct({ id: TodoIdSchema, intent: Schema.Literal("toggle") }), - Schema.Struct({ id: TodoIdSchema, intent: Schema.Literal("remove") }), - Schema.Struct({ intent: Schema.Literal("clear-completed") }), -]) - -export const TodoCommandResultSchema = Schema.Struct({ - intent: TodoIntentSchema, -}) - -export const TodoRouteErrorSchema = Schema.instanceOf(TodoNotFoundError) - -export const remainingTodoCount = (todos: ReadonlyArray): number => - todos.filter((todo) => !todo.completed).length - -export const completedTodoCount = (todos: ReadonlyArray): number => - todos.filter((todo) => todo.completed).length - -export const hasCompletedTodos = (todos: ReadonlyArray): boolean => todos.some((todo) => todo.completed) - -export const TodoPanel = Component.make().pipe( - Component.view(({ children }) => html`
${children}
`), -) - -export const TodoNotes = Component.make().pipe( - Component.view(() => - html` -
- - The composer now uses a template-authored form so input sync and submit parity stay inside html directives with no imperative seam. - - - The point of this example is architecture: Effect-backed service first, Loom route runtime second, and UI atoms as the projected view state. - -
- ` - ), -) - -export const TodoPageShell = Component.make().pipe( - Component.view(({ children }) => html`
${children}
`), -) diff --git a/apps/loom-example-app/src/todo-service.ts b/apps/loom-example-app/src/todo-service.ts deleted file mode 100644 index 3abfafb3..00000000 --- a/apps/loom-example-app/src/todo-service.ts +++ /dev/null @@ -1,109 +0,0 @@ -import * as Data from "effect/Data" -import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import * as Ref from "effect/Ref" - -export interface TodoItem { - readonly id: number - readonly title: string - readonly completed: boolean -} - -export type TodoCommand = - | { readonly intent: "create"; readonly title: string } - | { readonly intent: "toggle"; readonly id: number } - | { readonly intent: "remove"; readonly id: number } - | { readonly intent: "clear-completed" } - -export type TodoCommandResult = Readonly<{ - intent: TodoCommand["intent"] -}> - -export class TodoNotFoundError extends Data.TaggedError("TodoNotFoundError")<{ - readonly id: number -}> {} - -export interface TodoServiceApi { - readonly list: () => Effect.Effect> - readonly dispatch: (command: TodoCommand) => Effect.Effect - readonly reset: () => Effect.Effect -} - -export const initialTodoItems: ReadonlyArray = [ - { id: 1, title: "Sketch the shared Atom shape", completed: true }, - { id: 2, title: "Wire the composer to shared state", completed: false }, - { id: 3, title: "Show composition through child components", completed: false }, -] - -export const cloneInitialTodos = (): Array => initialTodoItems.map((todo) => ({ ...todo })) - -export class TodoService extends Context.Service()("LoomExampleTodoService", { - make: Effect.gen(function*() { - const todosRef = yield* Ref.make(cloneInitialTodos()) - const nextIdRef = yield* Ref.make(initialTodoItems.length + 1) - - return { - list: () => Ref.get(todosRef), - dispatch: (command: TodoCommand) => - Effect.gen(function*() { - switch (command.intent) { - case "create": { - const nextId = yield* Ref.get(nextIdRef) - const nextTodo: TodoItem = { - id: nextId, - title: command.title, - completed: false, - } - - yield* Ref.update(todosRef, (current) => [...current, nextTodo]) - yield* Ref.set(nextIdRef, nextId + 1) - - return { intent: command.intent } satisfies TodoCommandResult - } - case "toggle": { - const current = yield* Ref.get(todosRef) - - if (!current.some((todo) => todo.id === command.id)) { - return yield* Effect.fail(new TodoNotFoundError({ id: command.id })) - } - - yield* Ref.update(todosRef, (todos) => - todos.map((todo) => todo.id === command.id ? { ...todo, completed: !todo.completed } : todo)) - - return { intent: command.intent } satisfies TodoCommandResult - } - case "remove": { - const current = yield* Ref.get(todosRef) - - if ( - !current.some((todo) => - todo.id === command.id - ) - ) { - return yield* Effect.fail(new TodoNotFoundError({ id: command.id })) - } - - yield* Ref.update(todosRef, (todos) => todos.filter((todo) => todo.id !== command.id)) - - return { intent: command.intent } satisfies TodoCommandResult - } - case "clear-completed": { - yield* Ref.update(todosRef, (todos) => todos.filter((todo) => !todo.completed)) - - return { intent: command.intent } satisfies TodoCommandResult - } - } - }), - reset: () => - Effect.gen(function*() { - yield* Ref.set(todosRef, cloneInitialTodos()) - yield* Ref.set(nextIdRef, initialTodoItems.length + 1) - }), - } satisfies TodoServiceApi - }), -}) { - static readonly layer = Layer.effect(this, this.make) -} - -export const makeTodoService = (): TodoServiceApi => Effect.runSync(TodoService.make) diff --git a/apps/loom-example-app/tests/app-route-import-safety.test.ts b/apps/loom-example-app/tests/app-route-import-safety.test.ts deleted file mode 100644 index e12b049b..00000000 --- a/apps/loom-example-app/tests/app-route-import-safety.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" - -const importFresh = async (relativePath: string): Promise => { - const moduleUrl = new URL(relativePath, import.meta.url) - return import(`${moduleUrl.href}?t=${Date.now()}`) as Promise -} - -type EntryServerModule = typeof import("../src/entry-server.js") - -describe("loom example app import-time SSR safety", () => { - beforeEach(() => { - vi.resetModules() - Reflect.deleteProperty(globalThis, "document") - }) - - it("imports template-authored routes and router without installing a global document", async () => { - const counterRouteModule = await importFresh<{ - readonly CounterRoute: unknown - readonly counterRouteId: string - readonly counterRoutePath: string - readonly counterRouteTitle: string - readonly default: unknown - }>("../src/routes/counter-route.ts") - const todoRouteModule = await importFresh<{ - readonly action: unknown - readonly createTodoRouteRuntime: unknown - readonly default: unknown - readonly loader: unknown - readonly prepareTodoRoute: unknown - readonly resetTodoRouteExampleState: unknown - readonly submitTodoRoute: unknown - readonly todoPageRoute: Record - readonly todoRouteId: string - readonly todoRoutePath: string - readonly todoRouteTitle: string - }>("../src/routes/todo-route.ts") - const routerModule = await importFresh<{ - readonly appRouter: Record - readonly prepareAppRequest: unknown - readonly resetExampleState: unknown - readonly resolveAppRequest: unknown - readonly todoRouteId: string - readonly todoRoutePath: string - readonly todoRouteTitle: string - }>("../src/router.ts") - - expect(counterRouteModule.counterRouteId).toBe("counter") - expect(counterRouteModule.counterRoutePath).toBe("/") - expect(counterRouteModule.counterRouteTitle).toBe("Counter") - expect(counterRouteModule.default).toBe(counterRouteModule.CounterRoute) - - expect(todoRouteModule.todoRouteId).toBe("todo") - expect(todoRouteModule.todoRoutePath).toBe("/todos") - expect(todoRouteModule.todoRouteTitle).toBe("Todo app") - expect(todoRouteModule.default).toHaveProperty("registry") - expect(todoRouteModule.todoPageRoute.identifier).toBe(todoRouteModule.todoRouteId) - expect(todoRouteModule.todoPageRoute.path).toBe(todoRouteModule.todoRoutePath) - expect(typeof todoRouteModule.action).toBe("object") - expect(typeof todoRouteModule.createTodoRouteRuntime).toBe("function") - expect(typeof todoRouteModule.loader).toBe("object") - expect(typeof todoRouteModule.prepareTodoRoute).toBe("function") - expect(typeof todoRouteModule.resetTodoRouteExampleState).toBe("function") - expect(typeof todoRouteModule.submitTodoRoute).toBe("function") - - expect(routerModule.todoRouteId).toBe(todoRouteModule.todoRouteId) - expect(routerModule.todoRoutePath).toBe(todoRouteModule.todoRoutePath) - expect(routerModule.todoRouteTitle).toBe(todoRouteModule.todoRouteTitle) - expect(typeof routerModule.prepareAppRequest).toBe("function") - expect(typeof routerModule.resetExampleState).toBe("function") - expect(typeof routerModule.resolveAppRequest).toBe("function") - expect(routerModule.appRouter).toHaveProperty("identifier") - expect(Reflect.has(globalThis, "document")).toBe(false) - }) - - it("imports the server entry without installing a global document", async () => { - const entryServerModule = await importFresh("../src/entry-server.ts") - - expect(typeof entryServerModule.createServerRenderer).toBe("function") - expect(entryServerModule.createServerRenderer()).toMatchObject({ - name: "effectify:loom-nitro", - render: expect.any(Function), - }) - expect(Reflect.has(globalThis, "document")).toBe(false) - }) -}) diff --git a/apps/loom-example-app/tests/entry-client.test.ts b/apps/loom-example-app/tests/entry-client.test.ts deleted file mode 100644 index c536df41..00000000 --- a/apps/loom-example-app/tests/entry-client.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -// @vitest-environment jsdom - -import { beforeEach, describe, expect, it, vi } from "vitest" -import { bootstrapClient, startClientApp } from "../src/entry-client.js" -import { createServerRenderer } from "../src/entry-server.js" -import { resetExampleState } from "../src/router.js" - -const importFresh = async (relativePath: string): Promise => { - const moduleUrl = new URL(relativePath, import.meta.url) - return import(`${moduleUrl.href}?t=${Date.now()}`) as Promise -} - -const yieldToEventLoop = async (): Promise => { - await new Promise((resolve) => setTimeout(resolve, 0)) -} - -const expectElement = (value: Element | null, name: string): HTMLElement => { - if (!(value instanceof HTMLElement)) { - throw new Error(`expected ${name}`) - } - - return value -} - -const expectInputElement = (value: Element | null, name: string): HTMLInputElement => { - if (!(value instanceof HTMLInputElement)) { - throw new Error(`expected ${name}`) - } - - return value -} - -describe("loom example app client entry", () => { - beforeEach(() => { - resetExampleState() - }) - - it("reports a missing payload while leaving the SSR shell untouched", async () => { - document.body.innerHTML = '
server shell
' - const before = document.body.innerHTML - - const result = await bootstrapClient(document) - - expect(result.status).toBe("missing-payload") - expect(document.body.innerHTML).toBe(before) - }) - - it("leaves the server-rendered shell alone when the page already contains SSR html", async () => { - const renderer = createServerRenderer() - const result = await renderer.render({ - method: "GET", - url: "/", - headers: {}, - }) - - document.open() - document.write(result.html) - document.close() - const before = document.body.innerHTML - - const bootstrap = await startClientApp(document) - - expect(bootstrap.status).toBe("missing-payload") - expect(document.body.innerHTML).toBe(before) - }) - - it("uses the default server payload marker without requiring explicit bootstrap overrides", async () => { - const renderer = createServerRenderer() - const result = await renderer.render({ - method: "GET", - url: "/", - headers: {}, - }) - - document.documentElement.innerHTML = result.html - - const bootstrap = await bootstrapClient(document) - - expect(result.html).toContain('id="__loom_payload__"') - expect(bootstrap.status).toBe("missing-payload") - expect(bootstrap.diagnostics[0]?.issues[0]?.subject).toBe("__loom_payload__") - expect(document.body.textContent).toContain("Loom vNext counter") - }) - - it("accepts explicit bootstrap options for missing payload diagnostics", async () => { - document.body.innerHTML = '
server shell
' - - const result = await bootstrapClient(document, { payloadElementId: "loom-demo-payload" }) - - expect(result.status).toBe("missing-payload") - expect(result.diagnostics[0]?.issues[0]?.subject).toBe("loom-demo-payload") - }) - - it("mounts the counter route into an empty dev root and keeps the buttons interactive", async () => { - document.documentElement.innerHTML = ` - Loom Example App - -
- - - ` - window.history.replaceState({}, "", "/") - - const result = await startClientApp(document) - const count = () => document.querySelector('[data-counter-value="true"]')?.textContent - const normalizedCount = () => count()?.replace(/\s+/g, " ").trim() - const dynamicValue = () => document.querySelector('[data-counter-dynamic-value="true"]') - const reactiveCue = () => document.querySelector('[data-counter-reactive-cue="true"]') - const click = (actionName: "decrement" | "increment" | "reset") => { - const button = document.querySelector(`[data-counter-action="${actionName}"]`) - - if (!(button instanceof HTMLButtonElement)) { - throw new Error(`expected ${actionName} button`) - } - - button.click() - } - - expect(result.status).toBe("missing-payload") - expect(document.querySelectorAll('[data-app-shell="loom-example-app"]')).toHaveLength(1) - expect(normalizedCount()).toBe("Count: 2") - expect(document.body.textContent).toContain("Templates author this route now") - - const cueBefore = expectElement(reactiveCue(), "reactive cue") - const dynamicValueBefore = expectElement(dynamicValue(), "dynamic counter value") - - expect(cueBefore.dataset.counterTone).toBe("baseline") - expect(cueBefore.getAttribute("title")).toBe("Reactive cue tone: baseline (2)") - - click("increment") - await yieldToEventLoop() - expect(normalizedCount()).toBe("Count: 3") - - const cueAfterIncrement = expectElement(reactiveCue(), "reactive cue after increment") - const dynamicValueAfterIncrement = expectElement(dynamicValue(), "dynamic counter value after increment") - - expect(cueAfterIncrement).toBe(cueBefore) - expect(dynamicValueAfterIncrement).toBe(dynamicValueBefore) - expect(cueAfterIncrement.getAttribute("data-counter-tone")).toBe("rising") - expect(cueAfterIncrement.getAttribute("title")).toBe("Reactive cue tone: rising (3)") - - click("increment") - await yieldToEventLoop() - expect(normalizedCount()).toBe("Count: 4") - expect(expectElement(reactiveCue(), "reactive cue after second increment").getAttribute("title")).toBe( - "Reactive cue tone: rising (4)", - ) - - click("decrement") - await yieldToEventLoop() - expect(normalizedCount()).toBe("Count: 3") - - click("decrement") - await yieldToEventLoop() - expect(normalizedCount()).toBe("Count: 2") - expect(reactiveCue()?.getAttribute("data-counter-tone")).toBe("baseline") - expect(expectElement(reactiveCue(), "reactive cue after decrement").getAttribute("title")).toBe( - "Reactive cue tone: baseline (2)", - ) - - click("reset") - await yieldToEventLoop() - expect(normalizedCount()).toBe("Count: 2") - expect(document.body.textContent).toContain("mount(...)") - expect(document.title).toBe("Loom Example App · Counter") - }) - - it("handles delegated clicks that originate from button text nodes in the dev fallback", async () => { - document.documentElement.innerHTML = ` - Loom Example App - -
- - - ` - window.history.replaceState({}, "", "/") - - await startClientApp(document) - - const incrementButton = document.querySelector('[data-counter-action="increment"]') - - if (!(incrementButton instanceof HTMLButtonElement)) { - throw new Error("expected increment button") - } - - const labelNode = incrementButton.firstChild - - if (!(labelNode instanceof Text)) { - throw new Error("expected increment button text node") - } - - labelNode.dispatchEvent(new MouseEvent("click", { bubbles: true })) - - expect(document.querySelector('[data-counter-value="true"]')?.textContent?.replace(/\s+/g, " ").trim()).toBe( - "Count: 3", - ) - }) - - it("renders a minimal not-found message for non-root dev fallback paths", async () => { - document.documentElement.innerHTML = ` - Loom Example App - -
- - - ` - window.history.replaceState({}, "", "/missing") - - const result = await startClientApp(document) - - expect(result.status).toBe("missing-payload") - expect(document.body.textContent).toContain("Route not found") - expect(document.body.textContent).toContain("Requested path: /missing") - }) - - it("mounts the todo route into the dev fallback and keeps shared atoms in sync across sections", async () => { - document.documentElement.innerHTML = ` - Loom Example App - -
- - - ` - window.history.replaceState({}, "", "/todos") - - const result = await startClientApp(document) - const todoInput = () => document.querySelector('[data-todo-input="true"]') - const addTodoButton = () => document.querySelector('[data-todo-add-action="true"]') - const openCount = () => document.querySelector('[data-todo-open-count="true"]')?.textContent?.trim() - const completedCount = () => document.querySelector('[data-todo-completed-count="true"]')?.textContent?.trim() - const sessionCount = () => document.querySelector('[data-todo-session-count="true"]')?.textContent?.trim() - const clickButton = (selector: string) => { - const button = document.querySelector(selector) - - if (!(button instanceof HTMLButtonElement)) { - throw new Error(`expected button for ${selector}`) - } - - button.click() - } - - expect(result.status).toBe("missing-payload") - expect(document.querySelector('[data-route-view="todo"]')).not.toBeNull() - expect(openCount()).toBe("2") - expect(completedCount()).toBe("1") - expect(sessionCount()).toBe("Added from this mounted composer: 0") - - const input = expectInputElement(todoInput(), "todo input") - input.focus() - input.value = "Document the slot tradeoffs" - input.dispatchEvent(new Event("input", { bubbles: true })) - await yieldToEventLoop() - - expect(expectInputElement(todoInput(), "todo input after typing")).toBe(input) - expect(document.activeElement).toBe(input) - expect(input.value).toBe("Document the slot tradeoffs") - - const addButton = expectElement(addTodoButton(), "add todo button") - - addButton.click() - await yieldToEventLoop() - - expect(openCount()).toBe("3") - expect(sessionCount()).toBe("Added from this mounted composer: 1") - expect(expectInputElement(todoInput(), "todo input after add").value).toBe("") - expect(document.querySelector('[data-todo-item-id="4"]')?.textContent).toContain("Document the slot tradeoffs") - - clickButton('[data-todo-toggle-id="2"]') - await yieldToEventLoop() - expect(openCount()).toBe("2") - expect(completedCount()).toBe("2") - - clickButton('[data-todo-remove-id="1"]') - await yieldToEventLoop() - expect(openCount()).toBe("2") - expect(completedCount()).toBe("1") - expect(document.querySelector('[data-todo-item-id="1"]')).toBeNull() - - clickButton('[data-todo-clear-completed="true"]') - await yieldToEventLoop() - expect(openCount()).toBe("2") - expect(completedCount()).toBe("0") - expect(document.querySelector('[data-todo-item-id="2"]')).toBeNull() - expect(document.title).toBe("Loom Example App · Todo app") - }) - - it("submits the composer from the Enter key and preserves the same runtime behavior as the Add button", async () => { - document.documentElement.innerHTML = ` - Loom Example App - -
- - - ` - window.history.replaceState({}, "", "/todos") - - await startClientApp(document) - - const input = expectInputElement(document.querySelector('[data-todo-input="true"]'), "todo input") - const form = document.querySelector("form") - - if (!(form instanceof HTMLFormElement)) { - throw new Error("expected todo composer form") - } - - input.focus() - input.value = "Ship Enter-key parity" - input.dispatchEvent(new Event("input", { bubbles: true })) - form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })) - await yieldToEventLoop() - - expect(document.querySelector('[data-todo-open-count="true"]')?.textContent?.trim()).toBe("3") - expect(document.querySelector('[data-todo-session-count="true"]')?.textContent?.trim()).toBe( - "Added from this mounted composer: 1", - ) - expect(expectInputElement(document.querySelector('[data-todo-input="true"]'), "todo input after enter").value).toBe( - "", - ) - expect(document.querySelector('[data-todo-item-id="4"]')?.textContent).toContain("Ship Enter-key parity") - expect(document.querySelector('[data-todo-action-status="true"]')?.textContent?.trim()).toBe("success") - }) - - it("shows invalid action feedback when the template-authored submit path fails validation", async () => { - document.documentElement.innerHTML = ` - Loom Example App - -
- - - ` - window.history.replaceState({}, "", "/todos") - - await startClientApp(document) - - const form = document.querySelector("form") - - if (!(form instanceof HTMLFormElement)) { - throw new Error("expected todo composer form") - } - - form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })) - await yieldToEventLoop() - - expect(document.querySelector('[data-todo-feedback="true"]')?.textContent).toContain("length of at least 1") - expect(document.querySelector('[data-todo-open-count="true"]')?.textContent?.trim()).toBe("2") - expect(document.querySelector('[data-todo-action-status="true"]')?.textContent?.trim()).toBe("invalid-input") - }) - - it("self-starts from the browser entry module without requiring entry-browser.ts", async () => { - vi.resetModules() - document.documentElement.innerHTML = ` - Loom Example App - -
- - - ` - window.history.replaceState({}, "", "/") - - await importFresh("../src/entry-client.ts") - await yieldToEventLoop() - - expect(document.querySelector('[data-app-shell="loom-example-app"]')).not.toBeNull() - expect(document.querySelector('[data-counter-value="true"]')?.textContent?.replace(/\s+/g, " ").trim()).toBe( - "Count: 2", - ) - }) -}) diff --git a/apps/loom-example-app/tests/entry-server.test.ts b/apps/loom-example-app/tests/entry-server.test.ts deleted file mode 100644 index c235a224..00000000 --- a/apps/loom-example-app/tests/entry-server.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest" -import { createServerRenderer } from "../src/entry-server.js" -import { resetExampleState } from "../src/router.js" - -describe("loom example app server entry", () => { - beforeEach(() => { - Reflect.deleteProperty(globalThis, "document") - resetExampleState() - }) - - it("renders without requiring or mutating a global document", async () => { - expect(Reflect.has(globalThis, "document")).toBe(false) - - const renderer = createServerRenderer() - const result = await renderer.render({ - method: "GET", - url: "/", - headers: {}, - }) - - expect(result.status).toBe(200) - expect(result.html).toContain('data-route-view="counter"') - expect(Reflect.has(globalThis, "document")).toBe(false) - }) - - it("renders the single counter route inside the shared document shell", async () => { - const renderer = createServerRenderer() - const result = await renderer.render({ - method: "GET", - url: "/", - headers: {}, - }) - - expect(result.status).toBe(200) - expect(result.html).toContain("Loom Example App · Counter") - expect(result.html).toContain("Loom vNext counter") - expect(result.html).toContain('id="loom-root"') - expect(result.html).toContain('data-route-view="counter"') - expect(result.html).toContain('id="__loom_payload__"') - expect(result.html).toContain('src="/src/entry-client.ts"') - expect(result.html).toContain('data-counter-action="increment"') - expect(result.html).toContain('data-counter-value="true"') - expect(result.html).toContain('data-counter-dynamic-value="true"') - expect(result.html).toContain('data-counter-reactive-cue="true"') - expect(result.html).toContain("Templates author this route now") - expect(result.html).toContain("mount(...) to fill the empty root") - }) - - it("renders the todo route with shared-state sections and interactive controls", async () => { - const renderer = createServerRenderer() - const result = await renderer.render({ - method: "GET", - url: "/todos", - headers: {}, - }) - - expect(result.status).toBe(200) - expect(result.html).toContain("Loom Example App · Todo app") - expect(result.html).toContain("Loom vNext todo app") - expect(result.html).toContain('data-route-view="todo"') - expect(result.html).toContain('data-todo-input="true"') - expect(result.html).toContain('data-todo-add-action="true"') - expect(result.html).toContain('data-todo-list="true"') - expect(result.html).toContain('data-todo-session-count="true"') - expect(result.html).toContain('value=""') - expect(result.html).toContain(" { - const renderer = createServerRenderer() - const result = await renderer.render({ - method: "GET", - url: "/missing-route", - headers: {}, - }) - - expect(result.status).toBe(404) - expect(result.html).toContain("Loom Example App · Not Found") - expect(result.html).toContain("Route not found") - expect(result.html).toContain('data-route-view="not-found"') - expect(result.html).toContain("/missing-route") - }) -}) diff --git a/apps/loom-example-app/tests/jsdom.d.ts b/apps/loom-example-app/tests/jsdom.d.ts deleted file mode 100644 index 0b8e9c94..00000000 --- a/apps/loom-example-app/tests/jsdom.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module "jsdom" { - export const JSDOM: any -} diff --git a/apps/loom-example-app/tests/project-shape.test.ts b/apps/loom-example-app/tests/project-shape.test.ts deleted file mode 100644 index ccd57b7a..00000000 --- a/apps/loom-example-app/tests/project-shape.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { existsSync, readFileSync } from "node:fs" -import { pathToFileURL } from "node:url" -import { Html } from "@effectify/loom" -import { Router } from "@effectify/loom-router" -import { describe, expect, it } from "vitest" - -const readJson = (relativePath: string): unknown => - JSON.parse(readFileSync(new URL(relativePath, import.meta.url), "utf8")) - -describe("loom example app project shape", () => { - it("declares explicit Nx lint, test, and typecheck targets plus tsconfig references", () => { - const project = readJson("../project.json") as { - name: string - targets: Record - } - const tsconfig = readJson("../tsconfig.json") as { - references: ReadonlyArray<{ path: string }> - } - - expect(project.name).toBe("@effectify/loom-example-app") - expect(Object.keys(project.targets)).toEqual(expect.arrayContaining(["lint", "test", "typecheck"])) - expect(tsconfig.references).toEqual([ - { path: "./tsconfig.app.json" }, - { path: "./tsconfig.spec.json" }, - ]) - }) - - it("uses only the public Loom packages and registers the Loom Vite plugin", async () => { - const packageJson = readJson("../package.json") as { - dependencies: Record - } - const viteModuleUrl = pathToFileURL(new URL("../vite.config.mts", import.meta.url).pathname).href - const viteConfigModule = await import(viteModuleUrl) - const viteConfig = "default" in viteConfigModule ? viteConfigModule.default : viteConfigModule - const config = typeof viteConfig === "function" ? await viteConfig() : viteConfig - - expect(Object.keys(packageJson.dependencies)).toEqual( - expect.arrayContaining([ - "@effectify/loom", - "@effectify/loom-nitro", - "@effectify/loom-router", - "@effectify/loom-vite", - ]), - ) - expect(config.plugins.map((plugin: { name?: string }) => plugin.name)).toEqual( - expect.arrayContaining(["effectify:loom-vite"]), - ) - }) - - it("documents web:input and web:submit in the supported phase-1 directive list", () => { - const readme = readFileSync(new URL("../../../packages/loom/README.md", import.meta.url), "utf8") - - expect(readme).toContain( - "Supported phase-1 directives are limited to `web:click`, `web:input`, `web:submit`, `web:value` / `web:inputValue`, `web:hydrate`, `web:class`, and `web:style`.", - ) - }) - - it("exports behavior-first route contracts through public entry points without extra bootstrap files", async () => { - const counterRouteModule = await import( - pathToFileURL(new URL("../src/routes/counter-route.ts", import.meta.url).pathname).href - ) - const todoRouteModule = await import( - pathToFileURL(new URL("../src/routes/todo-route.ts", import.meta.url).pathname).href - ) - const todoRouteStateModule = await import( - pathToFileURL(new URL("../src/routes/todo-route-state.ts", import.meta.url).pathname).href - ) - const routerModule = await import(pathToFileURL(new URL("../src/router.ts", import.meta.url).pathname).href) - const entryServerModule = await import( - pathToFileURL(new URL("../src/entry-server.ts", import.meta.url).pathname).href - ) - - const counterResult = routerModule.resolveAppRequest("/") - const todoResult = routerModule.resolveAppRequest("/todos") - const missingResult = routerModule.resolveAppRequest("/missing") - const counterHtml = Html.renderToString(routerModule.bodyForResult(counterResult)) - const todoHtml = Html.renderToString(routerModule.bodyForResult(todoResult)) - const missingHtml = Html.renderToString(routerModule.bodyForResult(missingResult)) - const renderer = entryServerModule.createServerRenderer() - const documentResult = await renderer.render({ method: "GET", url: "/", headers: {} }) - - expect(counterRouteModule.default).toBe(counterRouteModule.CounterRoute) - expect(counterRouteModule.counterRoutePath).toBe("/") - expect(todoRouteModule.default.registry).toBe(todoRouteStateModule.todoRegistry) - expect(typeof todoRouteModule.createTodoRouteRuntime).toBe("function") - expect(typeof todoRouteModule.submitTodoRoute).toBe("function") - expect(typeof routerModule.prepareAppRequest).toBe("function") - expect(typeof routerModule.resetExampleState).toBe("function") - expect(Router.isResolveSuccess(counterResult)).toBe(true) - expect(Router.isResolveSuccess(todoResult)).toBe(true) - expect(Router.isResolveNotFound(missingResult)).toBe(true) - expect(routerModule.statusForResult(counterResult)).toBe(200) - expect(routerModule.statusForResult(todoResult)).toBe(200) - expect(routerModule.statusForResult(missingResult)).toBe(404) - expect(routerModule.titleForResult(counterResult)).toBe("Counter") - expect(routerModule.titleForResult(todoResult)).toBe("Todo app") - expect(counterHtml).toContain('data-route-view="counter"') - expect(counterHtml).toContain('data-counter-action="increment"') - expect(counterHtml).toContain('data-counter-reactive-cue="true"') - expect(todoHtml).toContain('data-route-view="todo"') - expect(todoHtml).toContain('data-todo-add-action="true"') - expect(todoHtml).toContain('data-todo-runtime-status="true"') - expect(todoHtml).toContain('data-todo-empty-state="true"') - expect(missingHtml).toContain('data-route-view="not-found"') - expect(missingHtml).toContain("Requested path: /missing") - expect(documentResult.html).toContain('') - expect(documentResult.html).toContain('id="loom-root"') - expect(documentResult.html).toContain('id="__loom_payload__"') - expect(documentResult.html).toContain('src="/src/entry-client.ts"') - expect(existsSync(new URL("../src/entry-browser.ts", import.meta.url))).toBe(false) - expect(existsSync(new URL("../src/jsdom.d.ts", import.meta.url))).toBe(false) - expect(existsSync(new URL("../src/router-runtime.ts", import.meta.url))).toBe(false) - expect(existsSync(new URL("../src/routes/todo-route-submission.ts", import.meta.url))).toBe(false) - expect(existsSync(new URL("../src/document.ts", import.meta.url))).toBe(false) - expect(existsSync(new URL("../src/app-config.ts", import.meta.url))).toBe(false) - }) -}) diff --git a/apps/loom-example-app/tests/public-api.types.ts b/apps/loom-example-app/tests/public-api.types.ts deleted file mode 100644 index 1cf6854a..00000000 --- a/apps/loom-example-app/tests/public-api.types.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type * as Loom from "@effectify/loom" -import { Router } from "@effectify/loom-router" -import { CounterRoute } from "../src/routes/counter-route.js" -import { - appRouter, - bodyForResult, - prepareAppRequest, - resetExampleState, - resolveAppRequest, - todoPageRoute, - todoRouteId, -} from "../src/router.js" -import { counterRouteId } from "../src/routes/counter-route.js" -import { createTodoRouteRuntime, submitTodoRoute } from "../src/routes/todo-route.js" - -type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 - ? true - : false -type Expect = Value - -const homeHref = Router.href(appRouter, counterRouteId) -const todoHref = Router.href(appRouter, todoPageRoute) -const homeBody: Loom.View.Child = bodyForResult(resolveAppRequest("https://effectify.dev/")) -const todoBody: Loom.View.Child = bodyForResult(resolveAppRequest("https://effectify.dev/todos")) -const counterComponent: Loom.Component.Component = CounterRoute -const todoRuntime = createTodoRouteRuntime() -const prepareRequestPromise = prepareAppRequest(new URL("https://effectify.dev/todos")) -const submitTodoPromise = submitTodoRoute({ intent: "clear-completed" }) -const resetResult = resetExampleState() - -type HomeHrefContract = Expect> -type TodoHrefContract = Expect> -type HomeBodyContract = Expect> -type TodoBodyContract = Expect> -type TodoRuntimeContract = Expect void>> -type PrepareRequestContract = Expect>> -type SubmitTodoContract = Expect< - Equal> -> -type ResetContract = Expect> - -// @ts-expect-error unknown route identifiers must fail before runtime -Router.href(appRouter, "settings") - -export const typecheckSmoke = { - appRouter, - counterComponent, - counterRouteId, - homeBody, - homeHref, - prepareRequestPromise, - resetResult, - submitTodoPromise, - todoBody, - todoHref, - todoRuntime, - todoRouteId, -} - -export type { - HomeBodyContract, - HomeHrefContract, - PrepareRequestContract, - ResetContract, - SubmitTodoContract, - TodoBodyContract, - TodoHrefContract, - TodoRuntimeContract, -} diff --git a/apps/loom-example-app/tests/router-runtime.test.ts b/apps/loom-example-app/tests/router-runtime.test.ts deleted file mode 100644 index 5663326c..00000000 --- a/apps/loom-example-app/tests/router-runtime.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import * as Effect from "effect/Effect" -import { Html } from "@effectify/loom" -import { Router } from "@effectify/loom-router" -import { describe, expect, it } from "vitest" -import { createTodoRouteRuntime } from "../src/routes/todo-route.js" -import { bodyForResult, resetExampleState, resolveAppRequest, statusForResult, titleForResult } from "../src/router.js" -import { type TodoItem, TodoNotFoundError, type TodoServiceApi } from "../src/todo-service.js" - -const makeTestTodoService = (seed: ReadonlyArray) => { - let todos = [...seed] - let nextId = seed.length + 1 - const calls = { - dispatch: 0, - list: 0, - } - - const todoService: TodoServiceApi = { - dispatch: (command) => - Effect.gen(function*() { - calls.dispatch += 1 - - switch (command.intent) { - case "create": - todos = [...todos, { completed: false, id: nextId++, title: command.title }] - return { intent: command.intent } - case "toggle": - if (!todos.some((todo) => todo.id === command.id)) { - return yield* Effect.fail(new TodoNotFoundError({ id: command.id })) - } - - todos = todos.map((todo) => todo.id === command.id ? { ...todo, completed: !todo.completed } : todo) - return { intent: command.intent } - case "remove": - if (!todos.some((todo) => todo.id === command.id)) { - return yield* Effect.fail(new TodoNotFoundError({ id: command.id })) - } - - todos = todos.filter((todo) => todo.id !== command.id) - return { intent: command.intent } - case "clear-completed": - todos = todos.filter((todo) => !todo.completed) - return { intent: command.intent } - } - }), - list: () => - Effect.sync(() => { - calls.list += 1 - return todos - }), - reset: () => - Effect.sync(() => { - todos = [...seed] - nextId = seed.length + 1 - }), - } - - return { - calls, - todoService, - } -} - -describe("loom example app todo router runtime", () => { - it("resolves / and /todos through the public router contract instead of source-shape assertions", () => { - resetExampleState() - const counterResult = resolveAppRequest("/") - const todoResult = resolveAppRequest("/todos") - - expect(Router.isResolveSuccess(counterResult)).toBe(true) - expect(Router.isResolveSuccess(todoResult)).toBe(true) - expect(statusForResult(counterResult)).toBe(200) - expect(statusForResult(todoResult)).toBe(200) - expect(titleForResult(counterResult)).toBe("Counter") - expect(titleForResult(todoResult)).toBe("Todo app") - expect(Html.renderToString(bodyForResult(counterResult))).toContain('data-route-view="counter"') - expect(Html.renderToString(bodyForResult(todoResult))).toContain('data-route-view="todo"') - expect(Html.renderToString(bodyForResult(todoResult))).toContain('data-todo-add-action="true"') - expect(Html.renderToString(bodyForResult(todoResult))).toContain('data-todo-runtime-status="true"') - }) - - it("executes the initial loader through the route runtime", async () => { - const { calls, todoService } = makeTestTodoService([ - { completed: false, id: 1, title: "Ship the loader demo" }, - ]) - const runtime = createTodoRouteRuntime({ todoService }) - - const loaded = await runtime.load() - - expect(loaded).toEqual({ - _tag: "success", - data: [{ completed: false, id: 1, title: "Ship the loader demo" }], - route: loaded.route, - }) - expect(calls).toEqual({ dispatch: 0, list: 1 }) - }) - - it("revalidates the loader after a successful action", async () => { - const { calls, todoService } = makeTestTodoService([ - { completed: false, id: 1, title: "Ship the loader demo" }, - ]) - const runtime = createTodoRouteRuntime({ todoService }) - - await runtime.load() - const result = await runtime.submit({ - submission: { intent: "create", title: "Close the runtime loop" }, - }) - - expect(result.action).toEqual({ - _tag: "success", - result: { intent: "create" }, - revalidated: false, - route: result.action.route, - }) - expect(result.loader).toEqual({ - _tag: "success", - data: [ - { completed: false, id: 1, title: "Ship the loader demo" }, - { completed: false, id: 2, title: "Close the runtime loop" }, - ], - route: result.loader?.route, - }) - expect(calls).toEqual({ dispatch: 1, list: 2 }) - }) - - it("returns invalid-input results without dispatching the action", async () => { - const { calls, todoService } = makeTestTodoService([ - { completed: false, id: 1, title: "Ship the loader demo" }, - ]) - const runtime = createTodoRouteRuntime({ todoService }) - - await runtime.load() - const result = await runtime.submit({ - submission: { intent: "create", title: " " }, - }) - - expect(result).toEqual({ - action: { - _tag: "invalid-input", - issues: [{ - _tag: "LoomRouterActionInputFailure", - input: { intent: "create", title: " " }, - message: expect.stringContaining("length of at least 1"), - }], - route: result.action.route, - submission: { intent: "create", title: " " }, - }, - }) - expect(calls).toEqual({ dispatch: 0, list: 1 }) - }) - - it("surfaces typed route action failures without revalidating the loader", async () => { - const { calls, todoService } = makeTestTodoService([ - { completed: false, id: 1, title: "Ship the loader demo" }, - ]) - const runtime = createTodoRouteRuntime({ todoService }) - - await runtime.load() - const result = await runtime.submit({ - submission: { id: "99", intent: "remove" }, - }) - - expect(result).toEqual({ - action: { - _tag: "failure", - error: expect.objectContaining({ - error: expect.objectContaining({ _tag: "TodoNotFoundError", id: 99 }), - }), - route: result.action.route, - }, - }) - expect(calls).toEqual({ dispatch: 1, list: 1 }) - }) -}) diff --git a/apps/loom-example-app/tests/todo-service.test.ts b/apps/loom-example-app/tests/todo-service.test.ts deleted file mode 100644 index 1970cb7b..00000000 --- a/apps/loom-example-app/tests/todo-service.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as Effect from "effect/Effect" -import { describe, expect, it } from "vitest" -import { makeTodoService, TodoService } from "../src/todo-service.js" - -describe("loom example todo service", () => { - it("creates and resets todos through the Context.Service runtime", async () => { - const service = makeTodoService() - - await Effect.runPromise(service.dispatch({ intent: "create", title: "Verify Context.Service migration" })) - - expect(await Effect.runPromise(service.list())).toEqual([ - { completed: true, id: 1, title: "Sketch the shared Atom shape" }, - { completed: false, id: 2, title: "Wire the composer to shared state" }, - { completed: false, id: 3, title: "Show composition through child components" }, - { completed: false, id: 4, title: "Verify Context.Service migration" }, - ]) - - await Effect.runPromise(service.reset()) - - expect(await Effect.runPromise(service.list())).toEqual([ - { completed: true, id: 1, title: "Sketch the shared Atom shape" }, - { completed: false, id: 2, title: "Wire the composer to shared state" }, - { completed: false, id: 3, title: "Show composition through child components" }, - ]) - }) - - it("fails with TodoNotFoundError for unknown ids", async () => { - await expect( - Effect.runPromise( - Effect.gen(function*() { - const service = yield* TodoService - return yield* service.dispatch({ id: 99, intent: "remove" }) - }).pipe(Effect.provide(TodoService.layer)), - ), - ).rejects.toMatchObject({ _tag: "TodoNotFoundError", id: 99 }) - }) -}) diff --git a/apps/loom-example-app/tsconfig.app.json b/apps/loom-example-app/tsconfig.app.json deleted file mode 100644 index 28316dab..00000000 --- a/apps/loom-example-app/tsconfig.app.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": [ - "src/**/*.d.ts", - "src/**/*.ts", - "src/**/*.mts" - ], - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["node", "vite/client"], - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - "skipLibCheck": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - } -} diff --git a/apps/loom-example-app/tsconfig.json b/apps/loom-example-app/tsconfig.json deleted file mode 100644 index c8ba5651..00000000 --- a/apps/loom-example-app/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "files": [], - "include": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.spec.json" } - ], - "compilerOptions": { - "strict": true - } -} diff --git a/apps/loom-example-app/tsconfig.spec.json b/apps/loom-example-app/tsconfig.spec.json deleted file mode 100644 index 8de0d717..00000000 --- a/apps/loom-example-app/tsconfig.spec.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "./tsconfig.app.json", - "include": [ - "vite.config.mts", - "tests/**/*.d.ts", - "tests/**/*.ts" - ], - "compilerOptions": { - "types": [ - "node", - "vite/client", - "vitest/globals", - "vitest/importMeta", - "vitest" - ] - } -} diff --git a/apps/loom-example-app/vite.config.mts b/apps/loom-example-app/vite.config.mts deleted file mode 100644 index 97585d1e..00000000 --- a/apps/loom-example-app/vite.config.mts +++ /dev/null @@ -1,21 +0,0 @@ -import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin" -import { LoomVite } from "../../packages/loom/vite/src/index.ts" -import { defineConfig } from "vitest/config" - -export default defineConfig({ - root: __dirname, - cacheDir: "../../node_modules/.vite/apps/loom-example-app", - plugins: [nxViteTsPaths(), LoomVite.loom()], - test: { - name: "@effectify/loom-example-app", - watch: false, - globals: true, - environment: "node", - include: ["tests/**/*.test.ts"], - reporters: ["default"], - coverage: { - reportsDirectory: "../../coverage/apps/loom-example-app", - provider: "v8", - }, - }, -}) diff --git a/apps/node-auth-example/tsconfig.json b/apps/node-auth-example/tsconfig.json index 2ba5d8a2..34924cac 100644 --- a/apps/node-auth-example/tsconfig.json +++ b/apps/node-auth-example/tsconfig.json @@ -3,5 +3,7 @@ "files": [], "include": [], "references": [{ "path": "./tsconfig.app.json" }], - "compilerOptions": { "strict": true } + "compilerOptions": { + "strict": true + } } diff --git a/apps/react-remix-example/.env-example b/apps/react-remix-example/.env-example deleted file mode 100644 index 3b233178..00000000 --- a/apps/react-remix-example/.env-example +++ /dev/null @@ -1,3 +0,0 @@ -DATABASE_URL=file:./dev.db -BETTER_AUTH_SECRET=qzh2jpxx5lZ0Y7Q4zepHYgpOfnTgDbF4 -BETTER_AUTH_URL=http://localhost:3000 \ No newline at end of file diff --git a/apps/react-remix-example/app/components/Nav.tsx b/apps/react-remix-example/app/components/Nav.tsx deleted file mode 100644 index 44091d4b..00000000 --- a/apps/react-remix-example/app/components/Nav.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { NavLink } from "@remix-run/react" -import { authClient } from "../lib/auth-client.js" -import { useNavigate } from "@remix-run/react" - -export function Nav() { - const navigate = useNavigate() - return ( - - ) -} diff --git a/apps/react-remix-example/app/entry.client.tsx b/apps/react-remix-example/app/entry.client.tsx deleted file mode 100644 index 85a15653..00000000 --- a/apps/react-remix-example/app/entry.client.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { RemixBrowser } from "@remix-run/react" -import { startTransition, StrictMode } from "react" -import { hydrateRoot } from "react-dom/client" - -startTransition(() => { - hydrateRoot( - document, - - - , - ) -}) diff --git a/apps/react-remix-example/app/entry.server.tsx b/apps/react-remix-example/app/entry.server.tsx deleted file mode 100644 index 2b4d009d..00000000 --- a/apps/react-remix-example/app/entry.server.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { PassThrough } from "node:stream" - -import type { EntryContext } from "@remix-run/node" -import { createReadableStreamFromReadable } from "@remix-run/node" -import { RemixServer } from "@remix-run/react" -import { isbot } from "isbot" -import { renderToPipeableStream } from "react-dom/server" - -const ABORT_DELAY = 5000 - -export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, - // loadContext: AppLoadContext -) { - const prohibitOutOfOrderStreaming = isBotRequest(request.headers.get("user-agent")) || remixContext.isSpaMode - - return prohibitOutOfOrderStreaming - ? handleBotRequest( - request, - responseStatusCode, - responseHeaders, - remixContext, - ) - : handleBrowserRequest( - request, - responseStatusCode, - responseHeaders, - remixContext, - ) -} - -// We have some Remix apps in the wild already running with isbot@3 so we need -// to maintain backwards compatibility even though we want new apps to use -// isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev. -function isBotRequest(userAgent: string | null) { - if (!userAgent) { - return false - } - - return isbot(userAgent) -} - -function handleBotRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, -) { - return new Promise((resolve, reject) => { - let shellRendered = false - const { pipe, abort } = renderToPipeableStream( - , - { - onAllReady() { - shellRendered = true - const body = new PassThrough() - const stream = createReadableStreamFromReadable(body) - - responseHeaders.set("Content-Type", "text/html") - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }), - ) - - pipe(body) - }, - onShellError(error: unknown) { - reject(error) - }, - onError(error: unknown) { - responseStatusCode = 500 - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error) - } - }, - }, - ) - - setTimeout(abort, ABORT_DELAY) - }) -} - -function handleBrowserRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, -) { - return new Promise((resolve, reject) => { - let shellRendered = false - const { pipe, abort } = renderToPipeableStream( - , - { - onShellReady() { - shellRendered = true - const body = new PassThrough() - const stream = createReadableStreamFromReadable(body) - - responseHeaders.set("Content-Type", "text/html") - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }), - ) - - pipe(body) - }, - onShellError(error: unknown) { - reject(error) - }, - onError(error: unknown) { - responseStatusCode = 500 - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error) - } - }, - }, - ) - - setTimeout(abort, ABORT_DELAY) - }) -} diff --git a/apps/react-remix-example/app/lib/auth-client.ts b/apps/react-remix-example/app/lib/auth-client.ts deleted file mode 100644 index 2f8bd362..00000000 --- a/apps/react-remix-example/app/lib/auth-client.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createAuthClient } from "better-auth/react" // make sure to import from better-auth/react - -export const authClient = createAuthClient({ - // you can pass client configuration here - baseURL: "http://localhost:3000", -}) diff --git a/apps/react-remix-example/app/lib/better-auth-options.server.ts b/apps/react-remix-example/app/lib/better-auth-options.server.ts deleted file mode 100644 index 6da4b97d..00000000 --- a/apps/react-remix-example/app/lib/better-auth-options.server.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { betterAuth } from "better-auth" -import { openAPI } from "better-auth/plugins" -import type { BetterAuthOptions } from "better-auth/types" -import type Database from "better-sqlite3" -import { database } from "./prisma.js" - -export const authOptions = { - baseURL: "http://localhost:3000", - secret: "hola", - emailAndPassword: { - enabled: true, - }, - database: database as Database.Database, - - advanced: { - defaultCookieAttributes: { - sameSite: "lax" as const, - secure: false, - path: "/", - }, - cookies: { - session_token: { - attributes: { - sameSite: "lax" as const, - secure: false, - path: "/", - }, - }, - }, - }, - plugins: [openAPI()], -} satisfies BetterAuthOptions - -export const auth = betterAuth(authOptions) diff --git a/apps/react-remix-example/app/lib/http.server.ts b/apps/react-remix-example/app/lib/http.server.ts deleted file mode 100644 index 688fc838..00000000 --- a/apps/react-remix-example/app/lib/http.server.ts +++ /dev/null @@ -1,53 +0,0 @@ -/*import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" -import { Effect, Layer, Schema } from "effect" - -class ApiGroup extends HttpApiGroup.make("api") - .add( - HttpApiEndpoint.get("getFirst", "/get-first") - .annotate(OpenApi.Description, "Get the first note, if there is one.") - .addSuccess( - Schema.Struct({ - id: Schema.String, - title: Schema.String, - content: Schema.String, - }), - ), - ) - .annotate(OpenApi.Title, "Notes") - .annotate(OpenApi.Description, "Operations on notes.") - .prefix("/api/notes") -{} - -export class Api extends HttpApi.make("Api") - .annotate(OpenApi.Title, "Confect Example") - .annotate( - OpenApi.Description, - ` -An example API built with Confect and powered by [Scalar](https://github.com/scalar/scalar). - -# Learn More - -See Scalar's documentation on [markdown support](https://github.com/scalar/scalar/blob/main/documentation/markdown.md) and [OpenAPI spec extensions](https://github.com/scalar/scalar/blob/main/documentation/openapi.md). - `, - ) - .prefix("/api") - .add(ApiGroup) -{} - -const ApiGroupLive = HttpApiBuilder.group( - Api, - "api", - (handlers) => - handlers.handle("getFirst", () => - Effect.gen(function*() { - const firstNote = { - id: "1", - title: "First Note", - content: "This is the first note", - } - return yield* Effect.succeed(firstNote) - })), -) - -export const ApiLive = HttpApiBuilder.api(Api).pipe(Layer.provide(ApiGroupLive)) -*/ diff --git a/apps/react-remix-example/app/lib/mockStore.ts b/apps/react-remix-example/app/lib/mockStore.ts deleted file mode 100644 index dc2aaf30..00000000 --- a/apps/react-remix-example/app/lib/mockStore.ts +++ /dev/null @@ -1,83 +0,0 @@ -export type TodoStatus = "PENDING" | "COMPLETED" - -export type Todo = { - id: string - title: string - content: string - status: TodoStatus - createdAt: string -} - -// Module-scoped in-memory storage -const mockStore: Todo[] = [ - { - id: "1", - title: "Learn Effect", - content: "Study Effect library fundamentals and patterns", - status: "PENDING", - createdAt: new Date().toISOString(), - }, - { - id: "2", - title: "Build Remix App", - content: "Create a CRUD application with Remix and Effect", - status: "PENDING", - createdAt: new Date().toISOString(), - }, - { - id: "3", - title: "Write Tests", - content: "Add comprehensive test coverage for the application", - status: "COMPLETED", - createdAt: new Date().toISOString(), - }, -] - -export const getTodos = (): Todo[] => { - return [...mockStore] -} - -export const getTodo = (id: string): Todo | undefined => { - return mockStore.find((todo) => todo.id === id) -} - -export const createTodo = (data: Omit): Todo => { - const newTodo: Todo = { - ...data, - id: crypto.randomUUID(), - createdAt: new Date().toISOString(), - } - mockStore.push(newTodo) - return newTodo -} - -export const updateTodo = ( - id: string, - data: Partial>, -): Todo | undefined => { - const index = mockStore.findIndex((todo) => todo.id === id) - if (index === -1) return undefined - - mockStore[index] = { - ...mockStore[index], - ...data, - } - return mockStore[index] -} - -export const deleteTodo = (id: string): boolean => { - const index = mockStore.findIndex((todo) => todo.id === id) - if (index === -1) return false - - mockStore.splice(index, 1) - return true -} - -export const toggleTodo = (id: string): Todo | undefined => { - const todo = getTodo(id) - if (!todo) return undefined - - return updateTodo(id, { - status: todo.status === "PENDING" ? "COMPLETED" : "PENDING", - }) -} diff --git a/apps/react-remix-example/app/lib/prisma.ts b/apps/react-remix-example/app/lib/prisma.ts deleted file mode 100644 index fa36bc3f..00000000 --- a/apps/react-remix-example/app/lib/prisma.ts +++ /dev/null @@ -1,10 +0,0 @@ -import "dotenv/config" -import Database, { Database as DatabaseType } from "better-sqlite3" - -const connectionString = process.env.DATABASE_URL -if (!connectionString || connectionString.trim().length === 0) { - throw new Error("Missing DATABASE_URL environment variable") -} - -const dbPath = connectionString.replace("file:", "") -export const database = new Database(dbPath) as DatabaseType diff --git a/apps/react-remix-example/app/lib/runtime.server.ts b/apps/react-remix-example/app/lib/runtime.server.ts deleted file mode 100644 index 21528120..00000000 --- a/apps/react-remix-example/app/lib/runtime.server.ts +++ /dev/null @@ -1,11 +0,0 @@ -import "dotenv/config" -import { Runtime } from "@effectify/react-remix" -import { AuthService } from "@effectify/node-better-auth" -import * as Layer from "effect/Layer" -import { authOptions } from "./better-auth-options.server.js" - -const Authlayer = AuthService.AuthServiceContext.layer(authOptions) - -const AppLayer = Layer.mergeAll(Authlayer) - -export const { withLoaderEffect, withActionEffect } = Runtime.make(AppLayer) diff --git a/apps/react-remix-example/app/root.tsx b/apps/react-remix-example/app/root.tsx deleted file mode 100644 index b8db03ae..00000000 --- a/apps/react-remix-example/app/root.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { HtmlLinkDescriptor } from "@remix-run/react" -import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react" -import { Nav } from "./components/Nav.js" - -export default function App() { - return ( - - - - - - - - -