diff --git a/.github/SETUP.md b/.github/SETUP.md index 5d64225e..d5ebda35 100644 --- a/.github/SETUP.md +++ b/.github/SETUP.md @@ -1,6 +1,6 @@ # CI and npm release setup -Effectify has three separate release channels. Beta now prepares a reviewable branch before a maintainer explicitly finalizes the merged release; alpha and stable behavior is unchanged. +Effectify releases through three isolated channels. Stable promotion is a two-stage beta → stable process with two reviewed release PRs; implementation PRs never publish directly. ## Release channel map @@ -10,24 +10,30 @@ Effectify has three separate release channels. Beta now prepares a reviewable br | Beta | Push to `master` | `beta` | `.github/workflows/cd.yml` | | Stable | Manual workflow against current `master` | default (`latest`) | `.github/workflows/release-stable.yml` | -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. +Alpha remains prerelease-only with `--tag=alpha`; beta remains prerelease-only with `--tag=beta`. Only stable omits a tag and may advance npm `latest`. -## Required repository setup +## Required repository and npm setup -Use Node.js 24.19.0 and pnpm 10.14.0 locally when reproducing workflow checks. +Use Node.js 24.19.0 and pnpm 10.14.0 when reproducing checks. -Configure these GitHub Actions secrets under **Settings > Secrets and variables > Actions**: +Configure `NPM_TOKEN` for the existing alpha and beta workflows. Stable publication does not use that secret: configure an npm trusted publisher for this repository, `.github/workflows/release-stable.yml`, and the `stable-release` GitHub environment. -| Secret | Purpose | -| --------------- | ----------------------------------------------------------------------------------------- | -| `NPM_TOKEN` | npm authentication and provenance publication | -| `RELEASE_TOKEN` | Optional checkout token for stable release git operations; `GITHUB_TOKEN` is the fallback | +Create the `stable-release` environment under **Settings > Environments** and require reviewers who are independent from the dispatcher. Restrict deployment branches to protected `master`. The environment is attached to the entire FINALIZE job. -The release jobs request `contents: write` for Nx release commits, tags, and GitHub releases, and `id-token: write` for npm provenance. +| Stable job | Declared job permissions | Explicit step environment and capability | +| ----------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| `validate` | `contents: read` | No secret/token environment; policy, install, build, and test only; checkout credentials are not stored | +| `prepare` | `contents: write` | `GH_TOKEN` is declared only on the release-branch push step; no OIDC; checkout credentials are not stored | +| `preflight` | `contents: read` | `GITHUB_TOKEN` is declared on the read-only API step; no npm credential or publication capability | +| `finalize` | `contents: write`, `id-token: write` | Protected environment applies to the job; publication environment is declared only on the finalizer step | + +FINALIZE installs required tooling with `--ignore-scripts`, forces `NPM_CONFIG_IGNORE_SCRIPTS=true` for the finalizer step and publish child, and runs no build, test, or package lifecycle scripts. Declared job permissions, including `contents` and `id-token`, are available job-wide; when `id-token: write` is declared, OIDC is not step-scoped. The only step-scoped credential controls are explicit secret or token environment variables on their listed API or mutation steps. This environment scoping is defense in depth; it does not turn job permissions into step-only capabilities. Every checkout sets `persist-credentials: false`, so checkout credentials are not persisted. + +The real stable publication boundary is protected `stable-release` environment review, authorization of the reviewed SHA, and npm trusted publishing bound to the repository, workflow, environment, and OIDC claims. `GITHUB_ACTIONS` is checked only as an accidental-use guard, so FINALIZE is refused outside GitHub Actions; it is not an unspoofable local security gate because a local process can set it. ## Nx release projects -All release workflows derive their allowlist from `nx.json`. The seven current Nx project names are: +The workflows derive the release allowlist from `nx.json.release.projects` and resolve each Nx project name and manifest. The current names are: 1. `@effectify/react-router` 2. `@effectify/react-query` @@ -37,102 +43,138 @@ All release workflows derive their allowlist from `nx.json`. The seven current N 6. `@effectify/prisma` 7. `@effectify/hatchet` -Use these project names—not filesystem paths—in manual workflow inputs. +Manual inputs use project names, not filesystem paths. A selection must be nonempty, duplicate-free, and a subset of the allowlist. Replace the shell values below with that exact normalized comma-separated subset and an approved issue number: + +```bash +PROJECTS='@effectify/solid-query' +ISSUE=123 +``` -## Exact workflow behavior +## Protected beta → stable quick path -### CI: `.github/workflows/ci.yml` +### 1. Produce and publish the beta prerequisite -**Triggers:** pull requests that are opened, synchronized, reopened, or marked ready for review, plus pushes to `dev`. +Merge the implementation through protected `master`. The resulting beta PREPARE selects affected release projects, changes only root `CHANGELOG.md` plus selected manifests, creates one release commit, and pushes `release/beta-`. -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: +Copy the branch from the workflow summary and open the first release PR. `Closes #$ISSUE` links the approved issue and `type:chore` must be the sole `type:*` label: ```bash -node --test scripts/release-policy-contract.test.mjs +BETA_BRANCH='release/beta-' +gh pr create --base master --head "$BETA_BRANCH" --title "chore(release): prepare beta" --body "Closes #$ISSUE" --label "type:chore" +BETA_PR=$(gh pr view "$BETA_BRANCH" --json number --jq '.number') +test "$(gh pr view "$BETA_PR" --json commits --jq '.commits | length')" = 1 ``` -### Alpha: `.github/workflows/release-alpha.yml` +Do not add commits to the generated branch. After required checks and review, merge or squash the single-commit PR; do not rebase-merge a multi-commit branch. Then finalize the selected beta subset at the exact current merged SHA: -**Triggers:** pushes to `dev` and optional manual dispatch. +```bash +BETA_SHA=$(gh api repos/{owner}/{repo}/git/ref/heads/master --jq '.object.sha') +[[ "$BETA_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1 +gh workflow run cd.yml --ref master -f publish_only=true -f projects="$PROJECTS" -f expected_sha="$BETA_SHA" +``` -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`. +Wait for beta FINALIZE to verify the selected `X.Y.Z-beta.N` versions and npm `beta` tags. Stable PREPARE requires those beta manifests; stable is not a shortcut around this prerequisite. -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. +### 2. Prepare the selected stable subset -### Beta: `.github/workflows/cd.yml` +Dispatch PREPARE with no SHA authorization inputs: -**Triggers:** pushes to `master` and manual dispatch. A normal eligible push prepares, but does not publish, a verified `release/beta-<12-character-source-sha>` branch. A beta release-shaped merge is suppressed structurally; `chore(release):` and `[skip release]` are defense-in-depth signals, not sufficient suppression by themselves. +```bash +gh workflow run release-stable.yml --ref master \ + -f projects="$PROJECTS" \ + -f publish_only=false \ + -f preflight_only=false \ + -f expected_sha= \ + -f artifact_sha= +``` -#### Beta quick path +The read-only `validate` job normalizes the selection, proves current `master`, runs the policy contract, installs, builds, and tests. The credential-isolated PREPARE job consumes that validated selection and SHA, derives strict `X.Y.Z-beta.N` → `X.Y.Z` transitions, and permits only root `CHANGELOG.md` plus selected manifests. -The published `@effectify/solid-query@0.5.12` collision cannot be repaired by moving `latest` back to the stale tarball. Recovery is strictly ordered: merge the implementation PR; run corrective beta PREPARE/PR/FINALIZE for only `@effectify/solid-query@0.5.13-beta.0`; then run the seven-project stable PREPARE/PR/FINALIZE ending at `@effectify/solid-query@0.5.13`. +Nx versioning cannot commit, tag, push, or stage. PREPARE creates exactly one local commit, then revalidates its parent, first-parent changed paths, source and target manifest identities, refs, clean tree, and fresh `origin/master`. Only the final dedicated push step declares `GH_TOKEN` in its environment and pushes `release/stable-`; the job's `contents: write` permission remains job-wide. PREPARE never publishes, tags, creates Releases, pushes `master`, or opens a PR. -1. Let an eligible `master` push run PREPARE. The existing incident PREPARE still requires all seven projects. The one corrective exception selects only `@effectify/solid-query`; the workflow fixes positional `prepatch`, `--preid=beta`, and all disabled git/staging effects, and accepts only root `CHANGELOG.md` plus `packages/solid/query/package.json` at `0.5.13-beta.0`. -2. Verify the summary's source SHA, release branch, changed paths, and versions. PREPARE changes only root `CHANGELOG.md` and the selected manifests. -3. Create or reuse the required approved issue. Manually open one linked PR from the reported release branch to `master`; its sole `type:*` label is `type:chore`. -4. Use ordinary required checks, human review, and protected merge. Confirm the merge-triggered beta run reports `suppress` and publishes nothing. -5. Copy the resulting current 40-character lowercase `master` SHA. Manually dispatch `publish_only=true` with the exact projects and that SHA as `expected_sha`. -6. Verify every exact annotated tag, non-draft GitHub prerelease, npm version, and `beta` dist-tag. +### 3. Review the second release PR -FINALIZE freshly checks that checkout `HEAD`, `origin/master`, and `expected_sha` are equal. It verifies or creates exact annotated tags with one atomic tag-only push, verifies or creates exact prereleases, publishes only missing npm packages through Nx with `--tag=beta`, and post-verifies every selected npm beta. Exact publish-only recovery retries are safe; unknown or conflicting external state stops the run. +Copy the stable branch from the summary and run the exact PR command to manually open its linked PR. This step is intentionally separate from PREPARE: -#### Authorized incident matrix +```bash +STABLE_BRANCH='release/stable-' +gh pr create --base master --head "$STABLE_BRANCH" --title "chore(release): promote stable" --body "Closes #$ISSUE" --label "type:chore" +STABLE_PR=$(gh pr view "$STABLE_BRANCH" --json number --jq '.number') +test "$(gh pr view "$STABLE_PR" --json commits --jq '.commits | length')" = 1 +``` -| Package | Required beta version | -| ------------------------------------- | --------------------- | -| `@effectify/react-router` | `0.6.0-beta.0` | -| `@effectify/react-query` | `1.0.0-beta.1` | -| `@effectify/node-better-auth` | `0.5.12-beta.0` | -| `@effectify/solid-query` | `0.5.12-beta.0` | -| `@effectify/react-router-better-auth` | `0.5.12-beta.0` | -| `@effectify/prisma` | `1.1.13-beta.0` | -| `@effectify/hatchet` | `0.1.0-beta.0` | +This is the **second reviewed release PR**. The beta release PR reviewed prerelease state; this PR reviews the exact beta-to-stable transitions. It must contain exactly root `CHANGELOG.md` and one manifest per selected project, with no extra path or extra commit. The sole `type:*` label is `type:chore`. -Do **not** dispatch stable during either beta recovery. Complete corrective beta PREPARE, its protected PR, and exact-SHA FINALIZE before starting the seven-project stable PREPARE. Stop on a newer `master`, an unexpected generated path or version, an ambiguous remote read, a lightweight or wrong-target tag, a conflicting Release, or inconsistent npm state. +Required checks, review, and branch protection authorize merge. For every PR whose head branch matches `release/stable-*`, the read-only release-policy CI guard validates the GitHub PR head ref plus full head/base SHAs, checks out the actual head, fetches the actual base, and requires `git rev-list --count base..head` to equal one. Extra source commits cannot pass this release PR gate. -Before publication, rollback is limited to deleting the unprotected prepared branch or closing/reverting the release PR through normal policy. Do not delete published tags, Releases, or npm artifacts as rollback; rerun the exact FINALIZE request or obtain authorization for a fix-forward release. +FINALIZE supports merge commits and squashes. Rebase merge is supported only for the single PREPARE commit that passed the release PR gate. A squash or single-commit rebase produces a one-parent artifact that presents the complete reviewed release shape in its first-parent diff. An accepted merge commit has exactly two parents: the first parent is protected `master`, the second parent is the single generated release commit based directly on that first parent, the merge tree exactly matches the second-parent tree, and the aggregate first-parent diff is the complete reviewed release shape. If `master` moves before a merge commit is created, rerun PREPARE instead of merging the stale branch. -### Stable: `.github/workflows/release-stable.yml` +FINALIZE retains its strict one-parent and exact two-parent graph validation. It cannot distinguish a squash from the last commit produced by a rebase and does not infer whether preceding source commits existed; the release PR CI gate enforces the one-source-commit invariant while GitHub still exposes the branch history. FINALIZE rejects octopus merges, a second parent based on any other commit, merge-time tree changes, and malformed reviewed diffs. The merge-triggered beta workflow must reach generic structural suppression and publish nothing. -Stable is a protected **PREPARE → manual authorization → FINALIZE** promotion, not a direct release. The first promotion is atomic and accepts exactly this matrix: +### 4. Capture exact SHAs and run PREFLIGHT -| Package | Beta source | Stable target | -| ------------------------------------- | --------------- | ------------- | -| `@effectify/hatchet` | `0.1.0-beta.0` | `0.1.0` | -| `@effectify/node-better-auth` | `0.5.12-beta.0` | `0.5.12` | -| `@effectify/prisma` | `1.1.13-beta.0` | `1.1.13` | -| `@effectify/react-query` | `1.0.0-beta.1` | `1.0.0` | -| `@effectify/react-router` | `0.6.0-beta.0` | `0.6.0` | -| `@effectify/react-router-better-auth` | `0.5.12-beta.0` | `0.5.12` | -| `@effectify/solid-query` | `0.5.13-beta.0` | `0.5.13` | +Immediately after the stable PR merges, obtain the full lowercase current `expected_sha` and reviewed `artifact_sha`: -#### Protected stable quick path +```bash +EXPECTED_SHA=$(gh api repos/{owner}/{repo}/git/ref/heads/master --jq '.object.sha') +ARTIFACT_SHA=$(gh pr view "$STABLE_PR" --json mergeCommit --jq '.mergeCommit.oid') +[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1 +[[ "$ARTIFACT_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1 +test "$EXPECTED_SHA" = "$ARTIFACT_SHA" +``` -1. Dispatch all seven project names with `publish_only=false` and leave `expected_sha` empty. PREPARE verifies current `master`, policy/build/test/readiness gates, and exact source versions. Nx materializes only `CHANGELOG.md` and the seven manifests with commit, tag, push, and staging disabled. -2. Read the secret-free summary and verify its source SHA, `release/stable-` branch, versions, and paths. Create or reuse the approved issue, then manually open its linked PR to protected `master`; the sole `type:*` label is `type:chore`. Required checks, review, and branch protection authorize merge. PREPARE does not create issues/PRs, publish, tag, create Releases, or push `master`. -3. Confirm the merge-triggered beta workflow reports structural stable suppression. Message text alone never suppresses; partial, mixed, malformed, or extra-path release shapes stop. -4. Capture the merged current lowercase 40-character `master` SHA. Dispatch the same seven projects with `publish_only=true` and that SHA as `expected_sha`. -5. FINALIZE reads all npm histories/`latest`, exact remote tags, and Releases before mutation, then reconciles **annotated exact-SHA tags → non-draft/non-prerelease Releases → missing-only npm publication**. Stable publication omits `--tag`, so it alone advances `latest`. +For the normal current release the values are identical. `expected_sha` authorizes the current `master`; `artifact_sha` identifies the reviewed release shape. A different artifact SHA is historical verification-only and cannot repair or publish. -Alpha remains prerelease-only with `--tag=alpha`; beta remains prerelease-only with annotated tags, prerelease Releases, and `--tag=beta`; stable has no prerelease suffix and never mutates prior alpha/beta artifacts. npm verification rereads at most six times with ten-second waits. Retry only the same exact SHA and matrix; matching artifacts are retained and only missing artifacts continue. +Dispatch read-only PREFLIGHT with the same normalized subset: -**Stop immediately** on a moved `master`, altered matrix, unexpected/staged/untracked path, moved ref, unreadable or malformed external state, lightweight/wrong-target/duplicate tag, draft/prerelease stable Release, stable collision, or an existing stable npm version whose `latest` differs. Do not independently repair a dist-tag. +```bash +gh workflow run release-stable.yml --ref master \ + -f projects="$PROJECTS" \ + -f publish_only=false \ + -f preflight_only=true \ + -f expected_sha="$EXPECTED_SHA" \ + -f artifact_sha="$ARTIFACT_SHA" +``` + +PREFLIGHT freshly proves `HEAD == origin/master == expected_sha`, derives the reviewed records from the artifact first-parent diff, and reads npm, tags, and Releases. It has no npm credentials, OIDC, or write token and performs no mutation. + +### 5. Dispatch protected FINALIZE + +After PREFLIGHT succeeds, dispatch FINALIZE with the same values: + +```bash +gh workflow run release-stable.yml --ref master \ + -f projects="$PROJECTS" \ + -f publish_only=true \ + -f preflight_only=false \ + -f expected_sha="$EXPECTED_SHA" \ + -f artifact_sha="$ARTIFACT_SHA" +``` -Before merge, abandon/delete only the prepared branch and PR. After merge but before any public artifact, cancel through a protected revert PR. After any public artifact exists, never delete, retarget, unpublish, deprecate, or rewrite it; stop and recover forward only through the same exact FINALIZE after state is authorized. +Protected-environment approval occurs before the privileged job. FINALIZE again proves the exact expected and artifact SHA authorization, then reconciles in order: -**Trigger:** manual dispatch only. The workflow has no push trigger. Use only the protected quick path above; the former direct graduation and publish-only recovery procedures are retired. +1. exact annotated tags targeting `artifact_sha`, with one atomic explicit tag-refspec push; +2. exact non-draft, non-prerelease GitHub Releases; +3. only npm packages still missing, through Nx without a prerelease tag; +4. bounded verification of every selected npm version and `latest`. -## Release safety checks +Matching state is retained, response loss is reconciled by rereading, and unknown or conflicting state stops the run. Publish-only recovery retries use the same workflow inputs. -Before any Nx version or publish command, every release workflow runs: +## Structural suppression and fail-closed behavior + +A `master` push containing root `CHANGELOG.md` and any nonempty subset of release manifests is suppressed only when every changed manifest keeps the catalog package name and changes strict `X.Y.Z-beta.N` to exactly `X.Y.Z`. The generic classifier handles a valid Solid Query-only subset exactly like any other subset. There is no package-specific corrective interception or permanent version matrix. + +Missing changelog, extra paths, package renames, leading-zero SemVer identifiers, partial transitions, mixed transitions, or unrelated target versions fail closed. Commit messages are only suspicious-shape defenses; they never authorize suppression. + +## Safety and recovery + +Before release mutation, the validation job runs: ```bash node --test scripts/release-policy-contract.test.mjs ``` -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. - -React Router publication readiness is verified with the maintained React Router 8 project and example targets: +React Router readiness is checked only when that project is selected: ```bash pnpm nx test @effectify/react-router @@ -142,10 +184,10 @@ pnpm nx run @effectify/react-router-example:migration:manifest pnpm nx run @effectify/react-router-example:consolidation:verify ``` -## Recovery checklist +**Stop immediately** on a moved `master`, changed selection, missing or invalid first parent, unexpected diff path, non-beta source, target other than the beta base, package rename, malformed external response, lightweight or wrong-target tag, conflicting Release, stable collision, or an existing npm version whose `latest` differs. + +Retry only the same exact SHAs and selected subset. Before a release PR merges, rollback is limited to abandoning the prepared branch or closing the PR. After merge but before public artifacts, use a protected revert PR. After any public artifact exists, never delete, retarget, unpublish, deprecate, or rewrite it; recover forward through the same authorized FINALIZE. + +## Residual GitHub-host assumptions -- Confirm the workflow run is using the intended channel and exact project names. -- For alpha recovery, confirm existing versions carry the alpha suffix. -- For beta recovery, follow the PREPARE → approved issue → linked PR → protected merge → exact-SHA FINALIZE path above. -- For stable recovery, confirm every selected manifest is stable and use stable's documented publish-only mode only for an existing version. -- Review workflow summaries, GitHub Releases, and npm package pages after completion. +The policy assumes GitHub correctly enforces job-scoped permissions, protected-environment reviewer rules, branch protection, masking of the job token, and exact workflow/commit checkout semantics. The one-source-commit gate additionally assumes GitHub's `pull_request` head ref, head SHA, and base SHA identify the current PR comparison, emits a fresh required check after head changes, and prevents merge when that check is stale or bypassed. Action references currently use reviewed moving major tags and are not immutable; this remains a supply-chain risk unless and until repository-wide commit-SHA pinning is adopted. It also assumes npm trusted publishing validates the repository, workflow filename, environment, and OIDC claims, and that the hosted npm CLI supports trusted publishing. These are host trust assumptions, not guarantees created by a locally unspoofable gate. diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 9c656118..67d5a36c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -85,12 +85,12 @@ jobs: run: | set -euo pipefail RELEASE_PROJECTS=$( - jq -r '.release.projects[]' nx.json | while read -r path; do - pnpm nx show project "$path" --json | jq -r '.name' + jq -r '.release.projects[]' nx.json | while read -r RELEASE_ROOT; do + pnpm nx show project "$RELEASE_ROOT" --json | jq -r '.name' done | jq -Rsc 'split("\n") | map(select(length > 0)) | unique' ) ALL_PROJECTS=$(printf '%s' "$RELEASE_PROJECTS" | jq -r '.[]' | sort) - test "$(printf '%s\n' "$ALL_PROJECTS" | sed '/^$/d' | wc -l | tr -d ' ')" = "7" + test -n "$ALL_PROJECTS" || { echo "Nx release project allowlist is empty" >&2; exit 1; } select_requested_projects() { test -n "$REQUESTED_PROJECTS" || { echo "manual beta runs require explicit projects" >&2; exit 1; } @@ -116,14 +116,7 @@ jobs: } echo "mode=finalize" >> "$GITHUB_OUTPUT" else - if cmp -s <(printf '%s\n' "$ALL_PROJECTS") <(printf '%s\n' "$SELECTED_PROJECTS"); then - echo "version_specifier=" >> "$GITHUB_OUTPUT" - elif [ "$SELECTED_PROJECTS" = "@effectify/solid-query" ]; then - echo "version_specifier=prepatch" >> "$GITHUB_OUTPUT" - else - echo "manual PREPARE requires all seven release projects or the corrective solid-query singleton" >&2 - exit 1 - fi + echo "version_specifier=" >> "$GITHUB_OUTPUT" echo "mode=prepare" >> "$GITHUB_OUTPUT" fi echo "has_projects=true" >> "$GITHUB_OUTPUT" @@ -132,65 +125,138 @@ jobs: fi ZERO_SHA="0000000000000000000000000000000000000000" + [[ "$BEFORE_SHA" =~ ^[0-9a-f]{40}$ ]] && [ "$BEFORE_SHA" != "$ZERO_SHA" ] || { + echo "master push requires a nonzero full lowercase before SHA" >&2 + exit 1 + } + [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] && [ "$HEAD_SHA" != "$ZERO_SHA" ] || { + echo "master push requires a nonzero full lowercase head SHA" >&2 + exit 1 + } 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 + test "$(git cat-file -t "$BEFORE" 2>/dev/null)" = "commit" || { + echo "master push before SHA is not a commit object" >&2 + exit 1 + } + test "$(git cat-file -t "$HEAD" 2>/dev/null)" = "commit" || { + echo "master push head SHA is not a commit object" >&2 + exit 1 + } + test "$(git rev-parse HEAD)" = "$HEAD" || { + echo "checked-out HEAD does not match the master push head SHA" >&2 + exit 1 + } + BASE="$BEFORE" - EXPECTED_PATHS=$(mktemp) - printf '%s\n' CHANGELOG.md packages/hatchet/package.json packages/node/better-auth/package.json packages/prisma/package.json packages/react/query/package.json packages/react/router/package.json packages/react/router-better-auth/package.json packages/solid/query/package.json | sort > "$EXPECTED_PATHS" - STABLE_TRANSITIONS=$(mktemp) - printf '%s\n' \ - '@effectify/hatchet=0.1.0-beta.0=0.1.0|packages/hatchet/package.json' \ - '@effectify/node-better-auth=0.5.12-beta.0=0.5.12|packages/node/better-auth/package.json' \ - '@effectify/prisma=1.1.13-beta.0=1.1.13|packages/prisma/package.json' \ - '@effectify/react-query=1.0.0-beta.1=1.0.0|packages/react/query/package.json' \ - '@effectify/react-router=0.6.0-beta.0=0.6.0|packages/react/router/package.json' \ - '@effectify/react-router-better-auth=0.5.12-beta.0=0.5.12|packages/react/router-better-auth/package.json' \ - '@effectify/solid-query=0.5.13-beta.0=0.5.13|packages/solid/query/package.json' > "$STABLE_TRANSITIONS" CHANGED=$(mktemp); git diff --name-only --no-renames "$BASE" "$HEAD" | sort -u > "$CHANGED" - CORRECTIVE_PATHS=$(mktemp) - printf '%s\n' CHANGELOG.md packages/solid/query/package.json | sort > "$CORRECTIVE_PATHS" - CORRECTIVE_TRANSITION='@effectify/solid-query=0.5.12-beta.0=0.5.13-beta.0|packages/solid/query/package.json' - if cmp -s "$CORRECTIVE_PATHS" "$CHANGED"; then - OLD_NAME=$(git show "$BASE:packages/solid/query/package.json" | jq -er .name) || exit 1 - OLD_VERSION=$(git show "$BASE:packages/solid/query/package.json" | jq -er .version) || exit 1 - NEW_NAME=$(jq -er .name packages/solid/query/package.json) || exit 1 - NEW_VERSION=$(jq -er .version packages/solid/query/package.json) || exit 1 - [ "$OLD_NAME" = "@effectify/solid-query" ] && [ "$NEW_NAME" = "@effectify/solid-query" ] && [ "$OLD_VERSION" = "0.5.12-beta.0" ] && [ "$NEW_VERSION" = "0.5.13-beta.0" ] || { echo "corrective beta shape is malformed" >&2; exit 1; } - echo "mode=suppress" >> "$GITHUB_OUTPUT"; echo "has_projects=false" >> "$GITHUB_OUTPUT"; echo "projects=" >> "$GITHUB_OUTPUT"; exit 0 - fi - STABLE_SHAPE=false - if cmp -s "$EXPECTED_PATHS" "$CHANGED"; then - STABLE_SHAPE=true - while IFS='|' read -r TRANSITION MANIFEST_PATH; do - NAME=${TRANSITION%%=*}; REST=${TRANSITION#*=}; OLD=${REST%%=*}; NEW=${REST##*=} - OLD_NAME=$(git show "$BASE:$MANIFEST_PATH" | jq -er .name) || STABLE_SHAPE=false - OLD_VERSION=$(git show "$BASE:$MANIFEST_PATH" | jq -er .version) || STABLE_SHAPE=false - NEW_NAME=$(jq -er .name "$MANIFEST_PATH") || STABLE_SHAPE=false - NEW_VERSION=$(jq -er .version "$MANIFEST_PATH") || STABLE_SHAPE=false - [ "$OLD_NAME" = "$NAME" ] && [ "$NEW_NAME" = "$NAME" ] && [ "$OLD_VERSION" = "$OLD" ] && [ "$NEW_VERSION" = "$NEW" ] || STABLE_SHAPE=false - done < "$STABLE_TRANSITIONS" - if [ "$STABLE_SHAPE" = true ]; then - echo "mode=suppress" >> "$GITHUB_OUTPUT"; echo "has_projects=false" >> "$GITHUB_OUTPUT"; echo "projects=" >> "$GITHUB_OUTPUT"; exit 0 + RELEASE_MANIFESTS=$(mktemp) + jq -r '.release.projects[]' nx.json | while read -r RELEASE_ROOT; do + DATA=$(pnpm nx show project "$RELEASE_ROOT" --json) + ROOT=$(printf '%s' "$DATA" | jq -er '.root | select(type == "string" and length > 0)') + NAME=$(printf '%s' "$DATA" | jq -er '.name | select(type == "string" and length > 0)') + test "$ROOT" = "$RELEASE_ROOT" || { echo "release project root mismatch for $NAME" >&2; exit 1; } + printf '%s\t%s/package.json\n' "$NAME" "$ROOT" + done | sort -k2,2 > "$RELEASE_MANIFESTS" + test -s "$RELEASE_MANIFESTS" || { echo "release manifest allowlist is empty" >&2; exit 1; } + + HEAD_SUBJECT=${HEAD_MESSAGE%%$'\n'*} + # release-policy-classifier:start + classify_push_shape() { + HAS_CHANGELOG=false + UNEXPECTED=false + MANIFEST_CHANGES=0 + BENIGN_MANIFEST_CHANGES=0 + BETA_TRANSITIONS=0 + INVALID_MANIFESTS=0 + while IFS= read -r CHANGED_PATH; do + if [ "$CHANGED_PATH" = "CHANGELOG.md" ]; then + if ! CHANGELOG_TYPE=$(git cat-file -t "$HEAD:CHANGELOG.md" 2>/dev/null) || [ "$CHANGELOG_TYPE" != "blob" ]; then + INVALID_MANIFESTS=$((INVALID_MANIFESTS + 1)) + continue + fi + HAS_CHANGELOG=true + continue + fi + if ! RECORD=$(awk -F '\t' -v manifest="$CHANGED_PATH" '$2 == manifest { print }' "$RELEASE_MANIFESTS"); then + INVALID_MANIFESTS=$((INVALID_MANIFESTS + 1)) + continue + fi + if [ -z "$RECORD" ]; then + UNEXPECTED=true + continue + fi + if [[ "$RECORD" == *$'\n'* ]]; then + INVALID_MANIFESTS=$((INVALID_MANIFESTS + 1)) + continue + fi + NAME=${RECORD%%$'\t'*} + MANIFEST_PATH=${RECORD#*$'\t'} + MANIFEST_CHANGES=$((MANIFEST_CHANGES + 1)) + if ! OLD_DOCUMENT=$(git show "$BASE:$MANIFEST_PATH" 2>/dev/null) || + ! NEW_DOCUMENT=$(git show "$HEAD:$MANIFEST_PATH" 2>/dev/null); then + INVALID_MANIFESTS=$((INVALID_MANIFESTS + 1)) + continue + fi + if ! printf '%s' "$OLD_DOCUMENT" | jq -e -s 'length == 1 and (.[0] | type == "object")' >/dev/null 2>&1 || + ! printf '%s' "$NEW_DOCUMENT" | jq -e -s 'length == 1 and (.[0] | type == "object")' >/dev/null 2>&1; then + INVALID_MANIFESTS=$((INVALID_MANIFESTS + 1)) + continue + fi + if ! OLD_NAME=$(printf '%s' "$OLD_DOCUMENT" | jq -er '.name | select(type == "string" and length > 0)' 2>/dev/null) || + ! OLD_VERSION=$(printf '%s' "$OLD_DOCUMENT" | jq -er '.version | select(type == "string" and length > 0)' 2>/dev/null) || + ! NEW_NAME=$(printf '%s' "$NEW_DOCUMENT" | jq -er '.name | select(type == "string" and length > 0)' 2>/dev/null) || + ! NEW_VERSION=$(printf '%s' "$NEW_DOCUMENT" | jq -er '.version | select(type == "string" and length > 0)' 2>/dev/null); then + INVALID_MANIFESTS=$((INVALID_MANIFESTS + 1)) + continue + fi + if [ "$OLD_NAME" != "$NAME" ] || [ "$NEW_NAME" != "$NAME" ]; then + INVALID_MANIFESTS=$((INVALID_MANIFESTS + 1)) + continue + fi + if [ "$OLD_VERSION" = "$NEW_VERSION" ]; then + BENIGN_MANIFEST_CHANGES=$((BENIGN_MANIFEST_CHANGES + 1)) + continue + fi + if [[ "$OLD_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.(0|[1-9][0-9]*)$ ]]; then + STABLE_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + if [ "$NEW_VERSION" = "$STABLE_VERSION" ]; then + BETA_TRANSITIONS=$((BETA_TRANSITIONS + 1)) + continue + fi + fi + INVALID_MANIFESTS=$((INVALID_MANIFESTS + 1)) + done < "$CHANGED" + + if [[ "$HEAD_SUBJECT" == *"chore(release):"* || "$HEAD_SUBJECT" == *"[skip release]"* ]]; then + printf '%s\n' reject + return + fi + if [ "$HAS_CHANGELOG" = "true" ] && [ "$UNEXPECTED" = "false" ] && [ "$INVALID_MANIFESTS" = "0" ] && [ "$BETA_TRANSITIONS" -gt 0 ] && [ "$BETA_TRANSITIONS" -eq "$MANIFEST_CHANGES" ]; then + printf '%s\n' suppress + return fi - echo "stable promotion shape is partial, mixed, or malformed" >&2; exit 1 + if [ "$HAS_CHANGELOG" = "true" ] || [ "$INVALID_MANIFESTS" -gt 0 ] || [ "$BETA_TRANSITIONS" -gt 0 ] || [ "$MANIFEST_CHANGES" -ne "$BENIGN_MANIFEST_CHANGES" ]; then + printf '%s\n' reject + return + fi + printf '%s\n' prepare + } + # release-policy-classifier:end + + CLASSIFICATION=$(classify_push_shape) + if [ "$CLASSIFICATION" = "suppress" ]; then + echo "mode=suppress" >> "$GITHUB_OUTPUT"; echo "has_projects=false" >> "$GITHUB_OUTPUT"; echo "projects=" >> "$GITHUB_OUTPUT"; exit 0 fi - HEAD_SUBJECT=${HEAD_MESSAGE%%$'\n'*} - if [[ "$HEAD_SUBJECT" == *"chore(release):"* || "$HEAD_SUBJECT" == *"[skip release]"* ]] || grep -Fxq CHANGELOG.md "$CHANGED"; then - echo "suspicious release-shaped master push; refusing preparation" >&2; exit 1 + if [ "$CLASSIFICATION" != "prepare" ]; then + echo "stable promotion shape is partial, mixed, or malformed" >&2 + echo "suspicious release-shaped master push; refusing preparation" >&2 + exit 1 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 "") + AFFECTED_RAW=$(pnpm nx show projects --affected --base="$BASE" --head="$HEAD" --json) + printf '%s' "$AFFECTED_RAW" | jq -e -s 'length == 1 and (.[0] | type == "array" and all(.[]; type == "string"))' >/dev/null + AFFECTED_RELEASE_PROJECTS=$(printf '%s' "$AFFECTED_RAW" | jq -r --argjson release "$RELEASE_PROJECTS" '[.[] | select(. as $project | $release | index($project))] | unique | join(",")') if [ -z "$AFFECTED_RELEASE_PROJECTS" ] || [ "$AFFECTED_RELEASE_PROJECTS" = "null" ]; then echo "mode=prepare" >> "$GITHUB_OUTPUT" echo "has_projects=false" >> "$GITHUB_OUTPUT" @@ -234,7 +300,6 @@ jobs: env: PROJECTS: ${{ steps.release.outputs.projects }} VERSION_SPECIFIER: ${{ steps.release.outputs.version_specifier }} - MANUAL_PREPARE: ${{ github.event_name == 'workflow_dispatch' }} run: | set -euo pipefail SOURCE_SHA=$(git rev-parse HEAD) @@ -279,27 +344,6 @@ jobs: } verify_prepared_tree - if [ "$MANUAL_PREPARE" = "true" ]; then - EXPECTED_MATRIX=$(mktemp) - if [ "$PROJECTS" = "@effectify/solid-query" ]; then - printf '%s\n' '@effectify/solid-query=0.5.13-beta.0' > "$EXPECTED_MATRIX" - else - printf '%s\n' \ - '@effectify/hatchet=0.1.0-beta.0' \ - '@effectify/node-better-auth=0.5.12-beta.0' \ - '@effectify/prisma=1.1.13-beta.0' \ - '@effectify/react-query=1.0.0-beta.1' \ - '@effectify/react-router=0.6.0-beta.0' \ - '@effectify/react-router-better-auth=0.5.12-beta.0' \ - '@effectify/solid-query=0.5.12-beta.0' | sort > "$EXPECTED_MATRIX" - fi - ACTUAL_MATRIX=$(mktemp) - while IFS=$'\t' read -r project name manifest; do - printf '%s=%s\n' "$name" "$(jq -er '.version' "$manifest")" - done < "$RECORDS" | sort > "$ACTUAL_MATRIX" - cmp -s "$EXPECTED_MATRIX" "$ACTUAL_MATRIX" || { echo "incident package/version matrix changed" >&2; exit 1; } - fi - pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 verify_prepared_tree git add --pathspec-from-file=/tmp/expected-release-paths diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c948a066..61cc3d92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,40 @@ jobs: name: 🛡️ Release Policy Contract if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest + permissions: + contents: read steps: + - name: 📥 Checkout stable release PR head + if: github.event_name == 'pull_request' && startsWith(github.event.pull_request.head.ref, 'release/stable-') + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: 🛡️ Require one stable release source commit + if: github.event_name == 'pull_request' && startsWith(github.event.pull_request.head.ref, 'release/stable-') + shell: bash + env: + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + [[ "$PR_HEAD_REF" == release/stable-* ]] || { echo '::error::invalid stable release head branch'; exit 1; } + [[ "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::stable release PR head SHA must be full lowercase hexadecimal'; exit 1; } + [[ "$PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::stable release PR base SHA must be full lowercase hexadecimal'; exit 1; } + test "$(git rev-parse HEAD)" = "$PR_HEAD_SHA" || { echo '::error::stable release PR checkout does not match the GitHub head SHA'; exit 1; } + git fetch --no-tags --no-write-fetch-head origin "$PR_BASE_SHA" + git cat-file -e "${PR_HEAD_SHA}^{commit}" + git cat-file -e "${PR_BASE_SHA}^{commit}" + SOURCE_COMMIT_COUNT=$(git rev-list --count "$PR_BASE_SHA..$PR_HEAD_SHA") + if [ "$SOURCE_COMMIT_COUNT" != 1 ]; then + echo "::error::stable release PR must contain exactly one source commit; found $SOURCE_COMMIT_COUNT" + exit 1 + fi + echo "Stable release PR contains exactly one source commit." + - name: 📥 Checkout uses: actions/checkout@v5 diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 55860ed3..5560de96 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -1,13 +1,14 @@ name: 🚀 Release Stable + on: workflow_dispatch: inputs: projects: - description: "Exact comma-separated seven-project stable matrix" + description: "Nonempty comma-separated subset of nx.json release projects" required: true type: string publish_only: - description: "FINALIZE the exact merged SHA" + description: "FINALIZE the exact reviewed merged SHA" required: true type: boolean default: false @@ -17,55 +18,74 @@ on: type: boolean default: false expected_sha: - description: "Full lowercase merged master SHA; PREFLIGHT or FINALIZE only" + description: "Full lowercase current master SHA; PREFLIGHT or FINALIZE only" required: false type: string artifact_sha: - description: "Full lowercase publication SHA; empty uses expected_sha, and a different SHA is historical verification-only" + description: "Full lowercase reviewed release SHA; empty uses expected_sha" required: false type: string default: "" + concurrency: group: release-stable cancel-in-progress: false + env: DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/effectify" + jobs: - release-stable: + validate: + name: 🔎 Validate stable request runs-on: ubuntu-latest permissions: - contents: write - id-token: write + contents: read + outputs: + mode: ${{ steps.release.outputs.mode }} + projects: ${{ steps.release.outputs.projects }} + validated_sha: ${{ steps.release.outputs.validated_sha }} + expected_sha: ${{ steps.release.outputs.expected_sha }} + artifact_sha: ${{ steps.release.outputs.artifact_sha }} services: postgres: image: postgres:16-alpine env: - { - POSTGRES_USER: postgres, - POSTGRES_PASSWORD: postgres, - POSTGRES_DB: effectify, - } - ports: ["5432:5432"] + 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 + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - - name: 📥 Checkout current master + - name: 📥 Checkout current master without credentials uses: actions/checkout@v5 with: ref: master fetch-depth: 0 - token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} - - uses: pnpm/action-setup@v6 - with: { version: 10.14.0 } - - uses: actions/setup-node@v5 + persist-credentials: false + + - 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/ - - run: pnpm install --frozen-lockfile + + - name: 📦 Install dependencies + run: pnpm install --frozen-lockfile + - name: 🛡️ Verify release policy contract run: node --test scripts/release-policy-contract.test.mjs - - name: 🧭 Resolve exact stable mode and matrix + + - name: 🧭 Resolve exact stable mode and selection id: release env: REQUESTED_PROJECTS: ${{ inputs.projects }} @@ -75,12 +95,29 @@ jobs: ARTIFACT_SHA: ${{ inputs.artifact_sha }} run: | set -euo pipefail + RELEASE_ROOTS=$(mktemp) + jq -r '.release.projects[]' nx.json > "$RELEASE_ROOTS" + test -s "$RELEASE_ROOTS" || { echo '::error::nx release project allowlist is empty'; exit 1; } + test -z "$(sort "$RELEASE_ROOTS" | uniq -d)" || { echo '::error::duplicate nx release root'; exit 1; } + ALLOWLIST=$(mktemp) + while IFS= read -r RELEASE_ROOT; do + DATA=$(pnpm nx show project "$RELEASE_ROOT" --json) + PROJECT=$(printf '%s' "$DATA" | jq -er '.name | select(type == "string" and length > 0)') + ROOT=$(printf '%s' "$DATA" | jq -er '.root | select(type == "string" and length > 0)') + test "$ROOT" = "$RELEASE_ROOT" || { echo "::error::release root mismatch for $PROJECT"; exit 1; } + printf '%s\n' "$PROJECT" >> "$ALLOWLIST" + done < "$RELEASE_ROOTS" + sort -u -o "$ALLOWLIST" "$ALLOWLIST" + test "$(wc -l < "$ALLOWLIST" | tr -d ' ')" = "$(wc -l < "$RELEASE_ROOTS" | tr -d ' ')" || { echo '::error::duplicate release project identity'; exit 1; } + RAW=$(printf '%s' "$REQUESTED_PROJECTS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d') - test -n "$RAW" || { echo '::error::stable matrix is empty'; exit 1; } + test -n "$RAW" || { echo '::error::stable selection is empty'; exit 1; } test -z "$(printf '%s\n' "$RAW" | sort | uniq -d)" || { echo '::error::duplicate stable project'; exit 1; } SELECTED=$(printf '%s\n' "$RAW" | sort) - EXPECTED=$(printf '%s\n' '@effectify/hatchet' '@effectify/node-better-auth' '@effectify/prisma' '@effectify/react-query' '@effectify/react-router' '@effectify/react-router-better-auth' '@effectify/solid-query' | sort) - cmp -s <(printf '%s\n' "$EXPECTED") <(printf '%s\n' "$SELECTED") || { echo '::error::stable requires exact seven-project matrix'; exit 1; } + while IFS= read -r project; do + grep -Fx -- "$project" "$ALLOWLIST" >/dev/null || { echo "::error::invalid stable project: $project"; exit 1; } + done <<< "$SELECTED" + if [ "$PREFLIGHT_ONLY" = true ] && [ "$PUBLISH_ONLY" = true ]; then echo '::error::preflight_only and publish_only are mutually exclusive' exit 1 @@ -97,95 +134,280 @@ jobs: test -z "$ARTIFACT_SHA" || { echo '::error::PREPARE rejects artifact_sha'; exit 1; } MODE=prepare fi - echo "mode=$MODE" >> "$GITHUB_OUTPUT" - echo "projects=$(printf '%s' "$SELECTED" | paste -sd, -)" >> "$GITHUB_OUTPUT" - - name: 🔒 Fresh master authorization - env: - MODE: ${{ steps.release.outputs.mode }} - EXPECTED_SHA: ${{ inputs.expected_sha }} - run: | - set -euo pipefail + git fetch origin master:refs/remotes/origin/master --no-tags HEAD_SHA=$(git rev-parse HEAD) REMOTE_SHA=$(git rev-parse origin/master) test "$HEAD_SHA" = "$REMOTE_SHA" || { echo '::error::checkout is not current origin/master'; exit 1; } + RESOLVED_ARTIFACT_SHA=${ARTIFACT_SHA:-$EXPECTED_SHA} if [ "$MODE" = preflight ] || [ "$MODE" = finalize ]; then test "$HEAD_SHA" = "$EXPECTED_SHA" || { echo '::error::PREFLIGHT/FINALIZE SHA mismatch'; exit 1; } + if ! ARTIFACT_CHANGELOG_TYPE=$(git cat-file -t "$RESOLVED_ARTIFACT_SHA:CHANGELOG.md" 2>/dev/null) || [ "$ARTIFACT_CHANGELOG_TYPE" != "blob" ]; then + echo '::error::reviewed stable artifact requires root CHANGELOG.md to exist as a blob' + exit 1 + fi fi + + echo "mode=$MODE" >> "$GITHUB_OUTPUT" + echo "projects=$(printf '%s' "$SELECTED" | paste -sd, -)" >> "$GITHUB_OUTPUT" + echo "validated_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "expected_sha=$EXPECTED_SHA" >> "$GITHUB_OUTPUT" + echo "artifact_sha=$RESOLVED_ARTIFACT_SHA" >> "$GITHUB_OUTPUT" + - name: 🏗️ Build selected projects - env: { PROJECTS: "${{ steps.release.outputs.projects }}" } + env: + PROJECTS: ${{ steps.release.outputs.projects }} run: pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 + - name: 🧪 Test selected projects - env: { PROJECTS: "${{ steps.release.outputs.projects }}" } + env: + PROJECTS: ${{ steps.release.outputs.projects }} run: pnpm nx run-many -t test "--projects=$PROJECTS" --parallel=3 --passWithNoTests + - name: ✅ Verify React Router readiness + if: ${{ contains(format(',{0},', steps.release.outputs.projects), ',@effectify/react-router,') }} 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 + + prepare: + name: 🌿 PREPARE protected stable branch + needs: validate + if: ${{ needs.validate.outputs.mode == 'prepare' }} + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + source_sha: ${{ steps.prepare.outputs.source_sha }} + release_sha: ${{ steps.prepare.outputs.release_sha }} + branch: ${{ steps.prepare.outputs.branch }} + versions: ${{ steps.prepare.outputs.versions }} + changed_paths: ${{ steps.prepare.outputs.changed_paths }} + steps: + - name: 📥 Checkout validated source without credentials + uses: actions/checkout@v5 + with: + ref: ${{ needs.validate.outputs.validated_sha }} + fetch-depth: 0 + persist-credentials: false + + - 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 + + - name: 📦 Install protected tooling + run: pnpm install --frozen-lockfile --ignore-scripts + - name: 🌿 PREPARE protected stable branch id: prepare - if: ${{ steps.release.outputs.mode == 'prepare' }} - env: { PROJECTS: "${{ steps.release.outputs.projects }}" } + env: + PROJECTS: ${{ needs.validate.outputs.projects }} + VALIDATED_SHA: ${{ needs.validate.outputs.validated_sha }} run: | set -euo pipefail - SOURCE_SHA=$(git rev-parse HEAD); SHA_PREFIX=${SOURCE_SHA:0:12}; BRANCH="release/stable-$SHA_PREFIX" - git config user.name 'github-actions[bot]'; git config user.email 'github-actions[bot]@users.noreply.github.com' - EXPECTED_PATHS=$(mktemp); printf '%s\n' CHANGELOG.md packages/hatchet/package.json packages/node/better-auth/package.json packages/prisma/package.json packages/react/query/package.json packages/react/router/package.json packages/react/router-better-auth/package.json packages/solid/query/package.json | sort > "$EXPECTED_PATHS" - RECORDS=$(mktemp); printf '%s\n' '@effectify/hatchet|packages/hatchet/package.json|0.1.0-beta.0|0.1.0' '@effectify/node-better-auth|packages/node/better-auth/package.json|0.5.12-beta.0|0.5.12' '@effectify/prisma|packages/prisma/package.json|1.1.13-beta.0|1.1.13' '@effectify/react-query|packages/react/query/package.json|1.0.0-beta.1|1.0.0' '@effectify/react-router|packages/react/router/package.json|0.6.0-beta.0|0.6.0' '@effectify/react-router-better-auth|packages/react/router-better-auth/package.json|0.5.12-beta.0|0.5.12' '@effectify/solid-query|packages/solid/query/package.json|0.5.13-beta.0|0.5.13' > "$RECORDS" - while IFS='|' read -r NAME MANIFEST_PATH OLD NEW; do if DETAIL=$(node -e 'const fs=require("node:fs");const [path,name,version]=process.argv.slice(1);let value;try{value=JSON.parse(fs.readFileSync(path,"utf8"))}catch{process.exit(2)}if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.name!=="string"||typeof value.version!=="string"||value.name!==name||value.version!==version){const actual={name:typeof value?.name==="string"?value.name:null,version:typeof value?.version==="string"?value.version:null};process.stdout.write(`actual=${JSON.stringify(actual)} expected=${JSON.stringify({name,version})}`);process.exit(1)}' "$MANIFEST_PATH" "$NAME" "$OLD"); then :; else STATUS=$?; if [ "$STATUS" = 1 ]; then echo "::error::source manifest identity mismatch for $NAME: $DETAIL"; else echo "::error::source manifest execution or parse failed for $NAME"; fi; exit 1; fi; done < "$RECORDS" + SOURCE_SHA=$(git rev-parse HEAD) + test "$SOURCE_SHA" = "$VALIDATED_SHA" || { echo '::error::PREPARE checkout differs from validated SHA'; exit 1; } + SHA_PREFIX=${SOURCE_SHA:0:12} + BRANCH="release/stable-$SHA_PREFIX" + git fetch origin master:refs/remotes/origin/master --no-tags + test "$(git rev-parse origin/master)" = "$SOURCE_SHA" || { echo '::error::validated master moved before PREPARE'; exit 1; } + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' test -z "$(git status --porcelain)" || { echo '::error::PREPARE requires clean tree'; exit 1; } REFS_BEFORE=$(git for-each-ref --format='%(refname) %(objectname)' refs/heads refs/tags | sort) - while IFS='|' read -r NAME MANIFEST_PATH OLD NEW; do - pnpm nx release version "$NEW" "--projects=$NAME" --git-commit=false --git-tag=false --git-push=false --stage-changes=false + + RECORDS=$(mktemp) + IFS=',' read -ra SELECTED <<< "$PROJECTS" + for PROJECT in "${SELECTED[@]}"; do + DATA=$(pnpm nx show project "$PROJECT" --json) + RESOLVED_PROJECT=$(printf '%s' "$DATA" | jq -er '.name | select(type == "string" and length > 0)') + ROOT=$(printf '%s' "$DATA" | jq -er '.root | select(type == "string" and length > 0)') + test "$RESOLVED_PROJECT" = "$PROJECT" || { echo "::error::project identity mismatch for $PROJECT"; exit 1; } + MANIFEST_PATH="$ROOT/package.json" + NAME=$(jq -er '.name | select(type == "string" and length > 0)' "$MANIFEST_PATH") + OLD=$(jq -er '.version | select(type == "string" and length > 0)' "$MANIFEST_PATH") + test "$NAME" = "$PROJECT" || { echo "::error::source manifest identity mismatch for $PROJECT"; exit 1; } + if [[ "$OLD" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.(0|[1-9][0-9]*)$ ]]; then + NEW="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + else + echo "::error::source manifest is not X.Y.Z-beta.N for $PROJECT" + exit 1 + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$PROJECT" "$ROOT" "$NAME" "$MANIFEST_PATH" "$OLD" "$NEW" >> "$RECORDS" + done + test "$(wc -l < "$RECORDS" | tr -d ' ')" = "${#SELECTED[@]}" || { echo '::error::stable record count mismatch'; exit 1; } + EXPECTED_PATHS=$(mktemp) + { printf '%s\n' CHANGELOG.md; cut -f4 "$RECORDS"; } | sort -u > "$EXPECTED_PATHS" + test "$(wc -l < "$EXPECTED_PATHS" | tr -d ' ')" = "$(( ${#SELECTED[@]} + 1 ))" || { echo '::error::stable manifest path collision'; exit 1; } + + while IFS=$'\t' read -r PROJECT ROOT NAME MANIFEST_PATH OLD NEW; do + pnpm nx release version "$NEW" "--projects=$PROJECT" --git-commit=false --git-tag=false --git-push=false --stage-changes=false done < "$RECORDS" test "$REFS_BEFORE" = "$(git for-each-ref --format='%(refname) %(objectname)' refs/heads refs/tags | sort)" || { echo '::error::Nx moved refs'; exit 1; } test -z "$(git diff --cached --name-only)" || { echo '::error::Nx staged files'; exit 1; } - ACTUAL=$(mktemp); { git diff --name-only --no-renames HEAD; git ls-files --others --exclude-standard; } | sort -u > "$ACTUAL" + ACTUAL=$(mktemp) + { git diff --name-only --no-renames HEAD; git ls-files --others --exclude-standard; } | sort -u > "$ACTUAL" cmp -s "$EXPECTED_PATHS" "$ACTUAL" || { echo '::error::unexpected PREPARE paths'; diff -u "$EXPECTED_PATHS" "$ACTUAL" || true; exit 1; } - while IFS='|' read -r NAME MANIFEST_PATH OLD NEW; do if DETAIL=$(node -e 'const fs=require("node:fs");const [path,name,version]=process.argv.slice(1);let value;try{value=JSON.parse(fs.readFileSync(path,"utf8"))}catch{process.exit(2)}if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.name!=="string"||typeof value.version!=="string"||value.name!==name||value.version!==version){const actual={name:typeof value?.name==="string"?value.name:null,version:typeof value?.version==="string"?value.version:null};process.stdout.write(`actual=${JSON.stringify(actual)} expected=${JSON.stringify({name,version})}`);process.exit(1)}' "$MANIFEST_PATH" "$NAME" "$NEW"); then :; else STATUS=$?; if [ "$STATUS" = 1 ]; then echo "::error::target manifest identity mismatch for $NAME: $DETAIL"; else echo "::error::target manifest execution or parse failed for $NAME"; fi; exit 1; fi; done < "$RECORDS" + while IFS=$'\t' read -r PROJECT ROOT NAME MANIFEST_PATH OLD NEW; do + ACTUAL_NAME=$(jq -er '.name | select(type == "string")' "$MANIFEST_PATH") + ACTUAL_VERSION=$(jq -er '.version | select(type == "string")' "$MANIFEST_PATH") + test "$ACTUAL_NAME" = "$NAME" && test "$ACTUAL_VERSION" = "$NEW" || { echo "::error::target manifest identity mismatch for $PROJECT"; exit 1; } + done < "$RECORDS" + git add --pathspec-from-file="$EXPECTED_PATHS" - git diff --cached --name-only --no-renames | sort > /tmp/stable-staged - cmp -s "$EXPECTED_PATHS" /tmp/stable-staged || { echo '::error::staged path contamination'; exit 1; } - git diff --quiet; test -z "$(git ls-files --others --exclude-standard)" + STAGED_PATHS=$(mktemp) + git diff --cached --name-only --no-renames | sort -u > "$STAGED_PATHS" + cmp -s "$EXPECTED_PATHS" "$STAGED_PATHS" || { echo '::error::staged path contamination'; exit 1; } + git diff --quiet + test -z "$(git ls-files --others --exclude-standard)" git commit -m "chore(release): prepare stable from $SOURCE_SHA [skip release]" || { echo '::error::PREPARE commit failed'; exit 1; } + + RELEASE_SHA=$(git rev-parse HEAD) + PARENTS=$(git rev-list --parents -n 1 "$RELEASE_SHA") + test "$PARENTS" = "$RELEASE_SHA $SOURCE_SHA" || { echo '::error::release commit must have exactly validated source as its parent'; exit 1; } + if ! RELEASE_CHANGELOG_TYPE=$(git cat-file -t "$RELEASE_SHA:CHANGELOG.md" 2>/dev/null) || [ "$RELEASE_CHANGELOG_TYPE" != "blob" ]; then + echo '::error::prepared stable artifact requires root CHANGELOG.md to exist as a blob' + exit 1 + fi + COMMITTED_PATHS=$(mktemp) + git diff --name-only --no-renames "$SOURCE_SHA" "$RELEASE_SHA" | sort -u > "$COMMITTED_PATHS" + cmp -s "$EXPECTED_PATHS" "$COMMITTED_PATHS" || { echo '::error::committed first-parent paths changed after commit hooks'; exit 1; } + while IFS=$'\t' read -r PROJECT ROOT NAME MANIFEST_PATH OLD NEW; do + SOURCE_DOCUMENT=$(git show "$SOURCE_SHA:$MANIFEST_PATH") + COMMITTED_DOCUMENT=$(git show "$RELEASE_SHA:$MANIFEST_PATH") + SOURCE_NAME=$(printf '%s' "$SOURCE_DOCUMENT" | jq -er '.name | select(type == "string")') + SOURCE_VERSION=$(printf '%s' "$SOURCE_DOCUMENT" | jq -er '.version | select(type == "string")') + COMMITTED_NAME=$(printf '%s' "$COMMITTED_DOCUMENT" | jq -er '.name | select(type == "string")') + COMMITTED_VERSION=$(printf '%s' "$COMMITTED_DOCUMENT" | jq -er '.version | select(type == "string")') + test "$SOURCE_NAME" = "$NAME" && test "$SOURCE_VERSION" = "$OLD" || { echo "::error::committed source identity changed for $PROJECT"; exit 1; } + test "$COMMITTED_NAME" = "$NAME" && test "$COMMITTED_VERSION" = "$NEW" || { echo "::error::committed target identity changed for $PROJECT"; exit 1; } + done < "$RECORDS" + git diff --quiet "$RELEASE_SHA" -- + git diff --cached --quiet "$RELEASE_SHA" -- test -z "$(git status --porcelain)" || { echo '::error::post-commit tree dirty'; exit 1; } - git push origin "HEAD:refs/heads/release/stable-$SHA_PREFIX" || { echo '::error::stable branch push failed'; exit 1; } - echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"; echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"; echo "changed_paths=$(paste -sd, "$EXPECTED_PATHS")" >> "$GITHUB_OUTPUT" + test "$REFS_BEFORE" = "$(git for-each-ref --format='%(refname) %(objectname)' refs/heads refs/tags | sort)" || { echo '::error::release commit changed a protected ref'; exit 1; } + git fetch origin master:refs/remotes/origin/master --no-tags + test "$(git rev-parse origin/master)" = "$SOURCE_SHA" || { echo '::error::master moved before stable branch push'; exit 1; } + + VERSIONS=$(while IFS=$'\t' read -r PROJECT ROOT NAME MANIFEST_PATH OLD NEW; do printf '%s=%s\n' "$NAME" "$NEW"; done < "$RECORDS" | paste -sd, -) + echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + echo "versions=$VERSIONS" >> "$GITHUB_OUTPUT" + echo "changed_paths=$(paste -sd, "$EXPECTED_PATHS")" >> "$GITHUB_OUTPUT" + + - name: 🚚 Push protected stable branch + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ steps.prepare.outputs.branch }} + run: | + set -euo pipefail + BASIC_AUTH=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n') + git -c http.https://github.com/.extraheader="AUTHORIZATION: basic $BASIC_AUTH" push origin "HEAD:refs/heads/$BRANCH" + + preflight: + name: 🔎 PREFLIGHT exact stable artifacts + needs: validate + if: ${{ needs.validate.outputs.mode == 'preflight' }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: 📥 Checkout validated master without credentials + uses: actions/checkout@v5 + with: + ref: ${{ needs.validate.outputs.validated_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: 🏗️ Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "24.19.0" + - name: 🔎 PREFLIGHT exact stable artifacts - if: ${{ steps.release.outputs.mode == 'preflight' }} env: - EXPECTED_SHA: ${{ inputs.expected_sha }} - ARTIFACT_SHA: ${{ inputs.artifact_sha }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PROJECTS: ${{ needs.validate.outputs.projects }} + EXPECTED_SHA: ${{ needs.validate.outputs.expected_sha }} + ARTIFACT_SHA: ${{ needs.validate.outputs.artifact_sha }} + GITHUB_TOKEN: ${{ github.token }} run: bash scripts/release-finalize-stable.sh --preflight --json - - name: 🔐 Verify npm authentication for FINALIZE - if: ${{ steps.release.outputs.mode == 'finalize' }} - env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" } - run: npm whoami + + finalize: + name: 🚀 FINALIZE exact stable artifacts + needs: validate + if: ${{ needs.validate.outputs.mode == 'finalize' }} + runs-on: ubuntu-latest + environment: stable-release + permissions: + contents: write + id-token: write + steps: + - name: 📥 Checkout validated master without credentials + uses: actions/checkout@v5 + with: + ref: ${{ needs.validate.outputs.validated_sha }} + fetch-depth: 0 + persist-credentials: false + + - 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 + + - name: 📦 Install publication tooling without lifecycle scripts + run: pnpm install --frozen-lockfile --ignore-scripts + - name: 🚀 FINALIZE exact stable artifacts - if: ${{ steps.release.outputs.mode == 'finalize' }} env: - PROJECTS: ${{ steps.release.outputs.projects }} - EXPECTED_SHA: ${{ inputs.expected_sha }} - ARTIFACT_SHA: ${{ inputs.artifact_sha }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PROJECTS: ${{ needs.validate.outputs.projects }} + EXPECTED_SHA: ${{ needs.validate.outputs.expected_sha }} + ARTIFACT_SHA: ${{ needs.validate.outputs.artifact_sha }} + GITHUB_TOKEN: ${{ github.token }} NPM_CONFIG_PROVENANCE: true + NPM_CONFIG_IGNORE_SCRIPTS: true run: bash scripts/release-finalize-stable.sh + + summary: + name: 📊 Stable summary + needs: [validate, prepare, preflight, finalize] + if: ${{ always() }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: - name: 📊 Stable summary - if: always() env: - MODE: ${{ steps.release.outputs.mode || 'failed' }} - PROJECTS: ${{ steps.release.outputs.projects || '' }} - SOURCE_SHA: ${{ steps.prepare.outputs.source_sha || '' }} - BRANCH: ${{ steps.prepare.outputs.branch || '' }} - EXPECTED_SHA: ${{ inputs.expected_sha || '' }} - ARTIFACT_SHA: ${{ inputs.artifact_sha || inputs.expected_sha || '' }} + MODE: ${{ needs.validate.outputs.mode || 'failed' }} + PROJECTS: ${{ needs.validate.outputs.projects || '' }} + VERSIONS: ${{ needs.prepare.outputs.versions || '' }} + CHANGED_PATHS: ${{ needs.prepare.outputs.changed_paths || '' }} + SOURCE_SHA: ${{ needs.prepare.outputs.source_sha || '' }} + RELEASE_SHA: ${{ needs.prepare.outputs.release_sha || '' }} + BRANCH: ${{ needs.prepare.outputs.branch || '' }} + EXPECTED_SHA: ${{ needs.validate.outputs.expected_sha || '' }} + ARTIFACT_SHA: ${{ needs.validate.outputs.artifact_sha || '' }} run: | echo '## Protected stable promotion' >> "$GITHUB_STEP_SUMMARY" - echo "**Mode:** $MODE" >> "$GITHUB_STEP_SUMMARY"; echo "**Projects:** $PROJECTS" >> "$GITHUB_STEP_SUMMARY" - echo "**Source:** $SOURCE_SHA **Branch:** $BRANCH **Expected SHA:** $EXPECTED_SHA **Artifact SHA:** $ARTIFACT_SHA" >> "$GITHUB_STEP_SUMMARY" - echo 'PREFLIGHT is read-only exact-state verification; it does not tag, push, create Releases, or publish. PREPARE requires a manually linked type:chore PR and protected review. FINALIZE reconciles tags → non-prerelease Releases → npm latest.' >> "$GITHUB_STEP_SUMMARY" + echo "**Mode:** $MODE" >> "$GITHUB_STEP_SUMMARY" + echo "**Projects:** $PROJECTS" >> "$GITHUB_STEP_SUMMARY" + echo "**Versions:** $VERSIONS **Changed paths:** $CHANGED_PATHS" >> "$GITHUB_STEP_SUMMARY" + echo "**Source:** $SOURCE_SHA **Release commit:** $RELEASE_SHA **Branch:** $BRANCH" >> "$GITHUB_STEP_SUMMARY" + echo "**Expected SHA:** $EXPECTED_SHA **Artifact SHA:** $ARTIFACT_SHA" >> "$GITHUB_STEP_SUMMARY" + echo 'PREFLIGHT is read-only exact-state verification; it does not tag, push, create Releases, or publish. PREPARE requires a linked type:chore PR and protected review. FINALIZE reconciles tags → non-prerelease Releases → npm latest.' >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/docs/astro.config.ts b/apps/docs/astro.config.ts index 18c62f84..5f93c215 100644 --- a/apps/docs/astro.config.ts +++ b/apps/docs/astro.config.ts @@ -10,6 +10,12 @@ import startlightThemeNova from "starlight-theme-nova" export default defineConfig({ site: "https://devx-op.github.io", base: "/effectify/", + redirects: { + "/solid/packages/solid-ui": "/solid/", + "/solid/packages/chat-solid": "/solid/", + "/es/solid/packages/solid-ui": "/es/solid/", + "/es/solid/packages/chat-solid": "/es/solid/", + }, integrations: [ starlight({ plugins: [ @@ -46,7 +52,7 @@ export default defineConfig({ }, { label: "Packages", - items: ["solid/packages/solid-effect-atom", "solid/packages/solid-query", "solid/packages/solid-ui"], + items: ["solid/packages/solid-effect-atom", "solid/packages/solid-query"], }, { label: "Reference", diff --git a/apps/docs/src/content/docs/es/solid/getting-started.md b/apps/docs/src/content/docs/es/solid/getting-started.md index 2c2d077b..f13c9a10 100644 --- a/apps/docs/src/content/docs/es/solid/getting-started.md +++ b/apps/docs/src/content/docs/es/solid/getting-started.md @@ -23,18 +23,6 @@ Elige los paquetes que necesitas: npm install @effectify/solid-query @tanstack/solid-query effect solid-js ``` -### Componentes de UI - -```bash -npm install @effectify/solid-ui -``` - -### Componentes de Chat - -```bash -npm install @effectify/chat-solid -``` - ## Configuración básica ### 1. Configurar TanStack Query diff --git a/apps/docs/src/content/docs/es/solid/index.mdx b/apps/docs/src/content/docs/es/solid/index.mdx index 9e2f27e6..60ba4da2 100644 --- a/apps/docs/src/content/docs/es/solid/index.mdx +++ b/apps/docs/src/content/docs/es/solid/index.mdx @@ -17,18 +17,6 @@ Effectify ofrece integraciones potentes para SolidJS que combinan la naturaleza [Más información →](packages/solid-query/) - - - Biblioteca de componentes de UI construida con SolidJS, Kobalte y Tailwind CSS. Incluye formularios, layouts y componentes interactivos optimizados para SolidJS. - - [Más información →](packages/solid-ui/) - - - - Componentes y servicios de chat en tiempo real para SolidJS. Construidos con Effect para estado robusto y SolidJS para actualizaciones reactivas. - - [Más información →](packages/chat-solid/) - ## Primeros pasos diff --git a/apps/docs/src/content/docs/es/solid/installation.md b/apps/docs/src/content/docs/es/solid/installation.md index ccc850fa..3ce0fe72 100644 --- a/apps/docs/src/content/docs/es/solid/installation.md +++ b/apps/docs/src/content/docs/es/solid/installation.md @@ -27,34 +27,6 @@ npm install @effectify/solid-query npm install @tanstack/solid-query effect solid-js ``` -### @effectify/solid-ui - -Biblioteca de componentes de UI con Kobalte y Tailwind CSS: - -```bash -npm install @effectify/solid-ui -``` - -**Peer Dependencies:** - -```bash -npm install solid-js tailwindcss @kobalte/core -``` - -### @effectify/chat-solid - -Componentes de chat en tiempo real: - -```bash -npm install @effectify/chat-solid -``` - -**Peer Dependencies:** - -```bash -npm install @effectify/solid-query @effectify/chat-domain solid-js -``` - ## Configuración específica por framework ### Vite + SolidJS @@ -168,35 +140,3 @@ export const QueryProvider: ParentComponent = (props) => { ``` - -## Configuración de Tailwind CSS - -Si usas `@effectify/solid-ui`, configura Tailwind: - -```bash -npm install -D tailwindcss postcss autoprefixer -npx tailwindcss init -p -``` - -`tailwind.config.js`: - -```js -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: [ - "./src/**/*.{js,ts,jsx,tsx}", - "./node_modules/@effectify/solid-ui/**/*.{js,ts,jsx,tsx}", - ], - theme: { extend: {} }, - plugins: [], -} -``` - -Directivas en CSS: - -```css -/* src/index.css */ -@tailwind base; -@tailwind components; -@tailwind utilities; -``` diff --git a/apps/docs/src/content/docs/es/solid/packages/solid-ui.md b/apps/docs/src/content/docs/es/solid/packages/solid-ui.md deleted file mode 100644 index e15eb8c9..00000000 --- a/apps/docs/src/content/docs/es/solid/packages/solid-ui.md +++ /dev/null @@ -1,201 +0,0 @@ ---- -title: "@effectify/solid-ui" -description: Biblioteca de componentes de UI para aplicaciones SolidJS ---- - -# @effectify/solid-ui - -El paquete `@effectify/solid-ui` proporciona un conjunto completo de componentes de UI construidos con SolidJS, Kobalte y Tailwind CSS. Incluye formularios, layouts, componentes interactivos y utilidades diseñadas para funcionar con aplicaciones basadas en Effect y el sistema reactivo de SolidJS. - -## Instalación - -```bash -npm install @effectify/solid-ui -``` - -**Peer Dependencies:** - -```bash -npm install solid-js tailwindcss @kobalte/core -``` - -## Configuración - -### 1. Configura Tailwind CSS - -```js -// tailwind.config.js -module.exports = { - content: [ - "./src/**/*.{js,ts,jsx,tsx}", - "./node_modules/@effectify/solid-ui/**/*.{js,ts,jsx,tsx}", - ], - theme: { extend: {} }, - plugins: [], -} -``` - -### 2. Importa estilos globales - -```tsx -// src/index.tsx -import "@effectify/solid-ui/globals.css" -``` - -## Componentes - -### Componentes de formulario - -#### Button - -```tsx -import { Button } from "@effectify/solid-ui/components/button" - -function MyComponent() { - return ( -
- - - - - - -
- ) -} -``` - -#### Input - -```tsx -import { Input } from "@effectify/solid-ui/components/input" -import { Label } from "@effectify/solid-ui/components/label" - -function LoginForm() { - return ( -
-
- - -
-
- - -
-
- ) -} -``` - -#### Form con TanStack Form - -```tsx -import { createForm } from "@tanstack/solid-form" -import { Button } from "@effectify/solid-ui/components/button" -import { Input } from "@effectify/solid-ui/components/input" -import { Label } from "@effectify/solid-ui/components/label" -import { Show } from "solid-js" - -interface LoginData { - email: string - password: string -} - -function LoginForm() { - const form = createForm(() => ({ - defaultValues: { email: "", password: "" } as LoginData, - onSubmit: async ({ value }) => { - console.log("Form data:", value) - }, - })) - - return ( -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - }} - class="space-y-4" - > - ( -
- - field().handleChange(e.currentTarget.value)} - type="email" - placeholder="Introduce tu email" - /> - 0}> -

{field().state.meta.errors.join(", ")}

-
-
- )} - /> - - ( -
- - field().handleChange(e.currentTarget.value)} - type="password" - placeholder="Introduce tu contraseña" - /> - 0}> -

{field().state.meta.errors.join(", ")}

-
-
- )} - /> - - - - ) -} -``` - -### Componentes de layout - -#### Card - -```tsx -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@effectify/solid-ui/components/card" -import { Button } from "@effectify/solid-ui/components/button" - -function UserCard(props: { user: User }) { - return ( - - - {props.user.name} - {props.user.email} - - -

Miembro desde {props.user.joinDate}

-
- - - - -
- ) -} -``` diff --git a/apps/docs/src/content/docs/es/solid/reference/api.md b/apps/docs/src/content/docs/es/solid/reference/api.md index 396bee84..c23b584e 100644 --- a/apps/docs/src/content/docs/es/solid/reference/api.md +++ b/apps/docs/src/content/docs/es/solid/reference/api.md @@ -1,11 +1,11 @@ --- title: Referencia de API de SolidJS -description: Referencia completa de API para los paquetes SolidJS de Effectify +description: Referencia de API de Effectify Solid Query --- # Referencia de API de SolidJS -Esta página proporciona una referencia completa de API para todos los paquetes SolidJS de Effectify. +Esta página cubre la integración de Effectify entre Effect y TanStack Query para SolidJS. ## @effectify/solid-query @@ -55,180 +55,3 @@ import { createResource } from "solid-js" const [user] = createResource(() => userId(), (id) => Effect.runPromise(fetchUserEffect(id))) ``` - -## @effectify/solid-ui - -### Componentes - -#### Button - -```tsx -interface ButtonProps extends JSX.ButtonHTMLAttributes { - variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" - size?: "default" | "sm" | "lg" | "icon" - children: JSX.Element -} -function Button(props: ButtonProps): JSX.Element -``` - -#### Input - -```tsx -interface InputProps extends JSX.InputHTMLAttributes { - error?: boolean -} -function Input(props: InputProps): JSX.Element -``` - -#### Componentes Card - -```tsx -interface CardProps extends JSX.HTMLAttributes { - children: JSX.Element -} -function Card(props: CardProps): JSX.Element -function CardHeader(props: CardProps): JSX.Element -function CardTitle(props: JSX.HTMLAttributes): JSX.Element -function CardDescription(props: JSX.HTMLAttributes): JSX.Element -function CardContent(props: CardProps): JSX.Element -function CardFooter(props: CardProps): JSX.Element -``` - -#### Componentes Dialog - -```tsx -interface DialogProps { - children: JSX.Element - open?: boolean - onOpenChange?: (open: boolean) => void -} -function Dialog(props: DialogProps): JSX.Element - -interface DialogTriggerProps { - children: JSX.Element - as?: Component -} -function DialogTrigger(props: DialogTriggerProps): JSX.Element -function DialogContent(props: JSX.HTMLAttributes): JSX.Element -function DialogHeader(props: JSX.HTMLAttributes): JSX.Element -function DialogTitle(props: JSX.HTMLAttributes): JSX.Element -function DialogDescription(props: JSX.HTMLAttributes): JSX.Element -function DialogFooter(props: JSX.HTMLAttributes): JSX.Element -``` - -#### Componentes Drawer - -```tsx -interface DrawerProps { - children: JSX.Element - open?: boolean - onOpenChange?: (open: boolean) => void -} -function Drawer(props: DrawerProps): JSX.Element -function DrawerTrigger(props: DialogTriggerProps): JSX.Element -function DrawerContent(props: JSX.HTMLAttributes): JSX.Element -function DrawerHeader(props: JSX.HTMLAttributes): JSX.Element -function DrawerTitle(props: JSX.HTMLAttributes): JSX.Element -function DrawerDescription(props: JSX.HTMLAttributes): JSX.Element -function DrawerFooter(props: JSX.HTMLAttributes): JSX.Element -``` - -### Utilidades - -#### `cn` - Utilidad de className - -```tsx -function cn(...inputs: ClassValue[]): string -``` - -Uso: - -```tsx -import { cn } from "@effectify/solid-ui/lib/utils" - -function MyComponent(props: { class?: string }) { - return
-} -``` - -#### Funciones de validación - -```tsx -function validateEmail(email: string): string | undefined -function validateRequired(value: any): string | undefined -function validateMinLength(value: string, min: number): string | undefined -function validateMaxLength(value: string, max: number): string | undefined -``` - -## @effectify/chat-solid - -### Componentes - -#### ChatProvider - -```tsx -interface ChatProviderProps { - userId: string - roomId: string - websocketUrl: string - options?: ChatOptions - children: JSX.Element -} -interface ChatOptions { - reconnectAttempts?: number - reconnectDelay?: number - messageHistory?: number - heartbeatInterval?: number - typingTimeout?: number -} -function ChatProvider(props: ChatProviderProps): JSX.Element -``` - -#### ChatRoom - -```tsx -interface ChatRoomProps extends JSX.HTMLAttributes { - showUserList?: boolean - showTypingIndicator?: boolean - messageLimit?: number -} -function ChatRoom(props: ChatRoomProps): JSX.Element -``` - -#### ChatMessages - -```tsx -interface ChatMessagesProps extends JSX.HTMLAttributes { - messages: Message[] - renderMessage?: (message: Message) => JSX.Element - onLoadMore?: () => void - hasMore?: boolean -} -function ChatMessages(props: ChatMessagesProps): JSX.Element -``` - -#### ChatInput - -```tsx -interface ChatInputProps extends JSX.HTMLAttributes { - onSendMessage: (message: { content: string; type: MessageType }) => void - disabled?: boolean - placeholder?: string - maxLength?: number - showEmojiPicker?: boolean - onTyping?: () => void - onStopTyping?: () => void -} -function ChatInput(props: ChatInputProps): JSX.Element -``` - -#### ChatUserList - -```tsx -interface ChatUserListProps extends JSX.HTMLAttributes { - users: User[] - renderUser?: (user: User) => JSX.Element - onUserClick?: (user: User) => void -} -function ChatUserList(props: ChatUserListProps): JSX.Element -``` diff --git a/apps/docs/src/content/docs/solid/getting-started.md b/apps/docs/src/content/docs/solid/getting-started.md index 302450c0..5ddef770 100644 --- a/apps/docs/src/content/docs/solid/getting-started.md +++ b/apps/docs/src/content/docs/solid/getting-started.md @@ -27,22 +27,6 @@ For data fetching with TanStack Query and Effect: npm install @effectify/solid-query @tanstack/solid-query effect solid-js ``` -### UI Components - -For pre-built UI components: - -```bash -npm install @effectify/solid-ui -``` - -### Chat Components - -For real-time chat functionality: - -```bash -npm install @effectify/chat-solid -``` - ## Basic Setup ### 1. Configure TanStack Query @@ -290,8 +274,6 @@ function UserDashboard(props: { userId: number }) { Now that you have the basics set up, explore the specific packages: - [Solid Query Integration](packages/solid-query/) - Learn advanced patterns for data fetching -- [UI Components](packages/solid-ui/) - Explore the component library -- [Chat Components](packages/chat-solid/) - Add real-time features ## Common Patterns diff --git a/apps/docs/src/content/docs/solid/index.mdx b/apps/docs/src/content/docs/solid/index.mdx index 2e0258e6..eab26468 100644 --- a/apps/docs/src/content/docs/solid/index.mdx +++ b/apps/docs/src/content/docs/solid/index.mdx @@ -17,18 +17,6 @@ Effectify provides powerful SolidJS integrations that combine the reactive natur [Learn more →](packages/solid-query/) - - - A comprehensive UI component library built with SolidJS, Kobalte, and Tailwind CSS. Includes forms, layouts, and interactive components optimized for SolidJS. - - [Learn more →](packages/solid-ui/) - - - - Real-time chat components and services for SolidJS applications. Built with Effect for robust state management and SolidJS for reactive UI updates. - - [Learn more →](packages/chat-solid/) - ## Getting Started diff --git a/apps/docs/src/content/docs/solid/installation.md b/apps/docs/src/content/docs/solid/installation.md index 1f5755cc..c6365810 100644 --- a/apps/docs/src/content/docs/solid/installation.md +++ b/apps/docs/src/content/docs/solid/installation.md @@ -27,34 +27,6 @@ npm install @effectify/solid-query npm install @tanstack/solid-query effect solid-js ``` -### @effectify/solid-ui - -UI component library with Kobalte and Tailwind CSS: - -```bash -npm install @effectify/solid-ui -``` - -**Peer Dependencies:** - -```bash -npm install solid-js tailwindcss @kobalte/core -``` - -### @effectify/chat-solid - -Real-time chat components: - -```bash -npm install @effectify/chat-solid -``` - -**Peer Dependencies:** - -```bash -npm install @effectify/solid-query @effectify/chat-domain solid-js -``` - ## Framework-Specific Setup ### Vite + SolidJS @@ -179,49 +151,6 @@ export const QueryProvider: ParentComponent = (props) => { ``` -## Tailwind CSS Setup - -If you're using `@effectify/solid-ui`, you'll need to configure Tailwind CSS: - -1. Install Tailwind CSS: - -```bash -npm install -D tailwindcss postcss autoprefixer -npx tailwindcss init -p -``` - -2. Configure `tailwind.config.js`: - -```js -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: [ - "./src/**/*.{js,ts,jsx,tsx}", - "./node_modules/@effectify/solid-ui/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: {}, - }, - plugins: [], -} -``` - -3. Add Tailwind directives to your CSS: - -```css -/* src/index.css */ -@tailwind base; -@tailwind components; -@tailwind utilities; -``` - -4. Import the CSS in your entry file: - -```tsx -// src/index.tsx -import "./index.css" -``` - ## TypeScript Configuration Ensure your `tsconfig.json` includes proper configuration for SolidJS: @@ -385,8 +314,6 @@ function App() { - [Getting Started Guide](getting-started/) - Learn the basics - [Solid Query Package](packages/solid-query/) - Explore data fetching patterns -- [UI Components](packages/solid-ui/) - Browse available components -- [Chat Components](packages/chat-solid/) - Add real-time features ## Troubleshooting diff --git a/apps/docs/src/content/docs/solid/packages/solid-ui.md b/apps/docs/src/content/docs/solid/packages/solid-ui.md deleted file mode 100644 index a72b55c8..00000000 --- a/apps/docs/src/content/docs/solid/packages/solid-ui.md +++ /dev/null @@ -1,641 +0,0 @@ ---- -title: "@effectify/solid-ui" -description: Comprehensive UI component library for SolidJS applications ---- - -# @effectify/solid-ui - -The `@effectify/solid-ui` package provides a comprehensive set of UI components built with SolidJS, Kobalte, and Tailwind CSS. It includes forms, layouts, interactive components, and utilities designed to work seamlessly with Effect-based applications and SolidJS's reactive system. - -## Installation - -```bash -npm install @effectify/solid-ui -``` - -**Peer Dependencies:** - -```bash -npm install solid-js tailwindcss @kobalte/core -``` - -## Setup - -### 1. Configure Tailwind CSS - -Add the package to your Tailwind config: - -```js -// tailwind.config.js -module.exports = { - content: [ - "./src/**/*.{js,ts,jsx,tsx}", - "./node_modules/@effectify/solid-ui/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: {}, - }, - plugins: [], -} -``` - -### 2. Import Global Styles - -```tsx -// src/index.tsx -import "@effectify/solid-ui/globals.css" -``` - -## Components - -### Form Components - -#### Button - -```tsx -import { Button } from "@effectify/solid-ui/components/button" - -function MyComponent() { - return ( -
- - - - - - -
- ) -} -``` - -#### Input - -```tsx -import { Input } from "@effectify/solid-ui/components/input" -import { Label } from "@effectify/solid-ui/components/label" - -function LoginForm() { - return ( -
-
- - -
-
- - -
-
- ) -} -``` - -#### Form with TanStack Form - -```tsx -import { createForm } from "@tanstack/solid-form" -import { Button } from "@effectify/solid-ui/components/button" -import { Input } from "@effectify/solid-ui/components/input" -import { Label } from "@effectify/solid-ui/components/label" - -interface LoginData { - email: string - password: string -} - -function LoginForm() { - const form = createForm(() => ({ - defaultValues: { - email: "", - password: "", - } as LoginData, - onSubmit: async ({ value }) => { - // Handle form submission with Effect - console.log("Form data:", value) - }, - })) - - return ( -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - }} - class="space-y-4" - > - ( -
- - field().handleChange(e.currentTarget.value)} - type="email" - placeholder="Enter your email" - /> - 0}> -

- {field().state.meta.errors.join(", ")} -

-
-
- )} - /> - - ( -
- - field().handleChange(e.currentTarget.value)} - type="password" - placeholder="Enter your password" - /> - 0}> -

- {field().state.meta.errors.join(", ")} -

-
-
- )} - /> - - - - ) -} -``` - -### Layout Components - -#### Card - -```tsx -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@effectify/solid-ui/components/card" -import { Button } from "@effectify/solid-ui/components/button" - -function UserCard(props: { user: User }) { - return ( - - - {props.user.name} - {props.user.email} - - -

Member since {props.user.joinDate}

-
- - - - -
- ) -} -``` - -### Interactive Components - -#### Dialog - -```tsx -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@effectify/solid-ui/components/dialog" -import { Button } from "@effectify/solid-ui/components/button" -import { createSignal } from "solid-js" - -function DeleteUserDialog(props: { onConfirm: () => void }) { - const [open, setOpen] = createSignal(false) - - return ( - - - Delete User - - - - Delete User - - Are you sure you want to delete this user? This action cannot be undone. - - - - - - - - - ) -} -``` - -#### Drawer (Mobile-friendly) - -```tsx -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@effectify/solid-ui/components/drawer" -import { Button } from "@effectify/solid-ui/components/button" - -function MobileMenu() { - return ( - - - Open Menu - - - - Navigation - - Choose where you'd like to go - - -
- - - -
- - - -
-
- ) -} -``` - -## Reactive Form Patterns - -### Form with SolidJS Signals - -```tsx -import { createSignal } from "solid-js" -import { Effect } from "effect" - -const submitLoginEffect = (data: LoginData) => - Effect.tryPromise({ - try: () => - fetch("/api/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }).then((res) => res.json()), - catch: (error) => new Error(`Login failed: ${error}`), - }) - -function ReactiveLoginForm() { - const [email, setEmail] = createSignal("") - const [password, setPassword] = createSignal("") - const [loading, setLoading] = createSignal(false) - const [error, setError] = createSignal(null) - - const handleSubmit = async (e: Event) => { - e.preventDefault() - setLoading(true) - setError(null) - - try { - await Effect.runPromise( - submitLoginEffect({ email: email(), password: password() }), - ) - // Handle success - } catch (err) { - setError(err instanceof Error ? err.message : "Login failed") - } finally { - setLoading(false) - } - } - - return ( -
-
- - setEmail(e.currentTarget.value)} - placeholder="Enter your email" - /> -
- -
- - setPassword(e.currentTarget.value)} - placeholder="Enter your password" - /> -
- - -

{error()}

-
- - -
- ) -} -``` - -### Form Validation with Effect - -```tsx -import { Effect, pipe } from "effect" - -class ValidationError { - readonly _tag = "ValidationError" - constructor(readonly errors: Record) {} -} - -const validateLoginForm = (data: LoginData) => - pipe( - Effect.succeed(data), - Effect.flatMap((data) => { - const errors: Record = {} - - if (!data.email) errors.email = "Email is required" - else if (!/\S+@\S+\.\S+/.test(data.email)) errors.email = "Invalid email" - - if (!data.password) errors.password = "Password is required" - else if (data.password.length < 6) errors.password = "Password must be at least 6 characters" - - return Object.keys(errors).length > 0 - ? Effect.fail(new ValidationError(errors)) - : Effect.succeed(data) - }), - ) - -function ValidatedLoginForm() { - const [formData, setFormData] = createSignal({ email: "", password: "" }) - const [errors, setErrors] = createSignal>({}) - const [loading, setLoading] = createSignal(false) - - const handleSubmit = async (e: Event) => { - e.preventDefault() - setLoading(true) - setErrors({}) - - try { - const validatedData = await Effect.runPromise( - validateLoginForm(formData()), - ) - - await Effect.runPromise(submitLoginEffect(validatedData)) - // Handle success - } catch (err) { - if (err instanceof ValidationError) { - setErrors(err.errors) - } - } finally { - setLoading(false) - } - } - - return ( -
-
- - - setFormData((prev) => ({ - ...prev, - email: e.currentTarget.value, - }))} - placeholder="Enter your email" - class={errors().email ? "border-red-500" : ""} - /> - -

{errors().email}

-
-
- -
- - - setFormData((prev) => ({ - ...prev, - password: e.currentTarget.value, - }))} - placeholder="Enter your password" - class={errors().password ? "border-red-500" : ""} - /> - -

{errors().password}

-
-
- - -
- ) -} -``` - -## Utilities - -### Class Name Utilities - -```tsx -import { cn } from "@effectify/solid-ui/lib/utils" - -function MyComponent(props: { class?: string }) { - return ( -
- ) -} -``` - -### Validation Utilities - -```tsx -import { validateEmail, validateRequired } from "@effectify/solid-ui/lib/validation" - -const form = createForm(() => ({ - defaultValues: { email: "" }, - validators: { - onChange: ({ value }) => ({ - fields: { - email: validateEmail(value.email) || validateRequired(value.email), - }, - }), - }, -})) -``` - -## Theming - -### CSS Variables - -The components use CSS variables for theming: - -```css -:root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96%; - --secondary-foreground: 222.2 47.4% 11.2%; - /* ... more variables */ -} - -.dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - /* ... dark theme variables */ -} -``` - -### Component Variants - -Components use `class-variance-authority` for variant management: - -```tsx -import { Button } from '@effectify/solid-ui/components/button' - -// Built-in variants - - - - - - - -// Size variants - - - - -``` - -## Available Components - -- **Forms**: Button, Input, Label, Textarea, Select, Checkbox, Radio -- **Layout**: Card, Container, Grid, Stack -- **Navigation**: Tabs, Breadcrumb, Pagination -- **Feedback**: Alert, Toast, Progress, Spinner -- **Overlay**: Dialog, Popover, Tooltip, Sheet, Drawer -- **Data Display**: Table, Badge, Avatar, Separator - -## Best Practices - -### 1. Use Semantic HTML - -```tsx -// Good - - -// Better - -``` - -### 2. Handle Loading States - -```tsx -function SubmitButton(props: { isLoading: boolean }) { - return ( - - ) -} -``` - -### 3. Use Reactive Patterns - -```tsx -function DynamicForm() { - const [formType, setFormType] = createSignal<"login" | "register">("login") - - return ( -
- - - - - - - - -
- ) -} -``` - -## Examples - -Check out the complete component usage in: - -- [SolidJS SPA example](https://github.com/devx-op/effectify/tree/main/apps/solid-app-spa) -- [SolidJS Start example](https://github.com/devx-op/effectify/tree/main/apps/solid-app-start) diff --git a/apps/docs/src/content/docs/solid/reference/api.md b/apps/docs/src/content/docs/solid/reference/api.md index 410b11ef..67fa1e1c 100644 --- a/apps/docs/src/content/docs/solid/reference/api.md +++ b/apps/docs/src/content/docs/solid/reference/api.md @@ -1,11 +1,11 @@ --- title: SolidJS API Reference -description: Complete API reference for Effectify SolidJS packages +description: API reference for Effectify Solid Query --- # SolidJS API Reference -This page provides a comprehensive API reference for all Effectify SolidJS packages. +This page covers the Effectify integration between Effect and TanStack Query for SolidJS. ## @effectify/solid-query @@ -59,322 +59,6 @@ const [user] = createResource( ) ``` -## @effectify/solid-ui - -### Components - -#### Button - -```tsx -interface ButtonProps extends JSX.ButtonHTMLAttributes { - variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" - size?: "default" | "sm" | "lg" | "icon" - children: JSX.Element -} - -function Button(props: ButtonProps): JSX.Element -``` - -#### Input - -```tsx -interface InputProps extends JSX.InputHTMLAttributes { - error?: boolean -} - -function Input(props: InputProps): JSX.Element -``` - -#### Card Components - -```tsx -interface CardProps extends JSX.HTMLAttributes { - children: JSX.Element -} - -function Card(props: CardProps): JSX.Element -function CardHeader(props: CardProps): JSX.Element -function CardTitle(props: JSX.HTMLAttributes): JSX.Element -function CardDescription(props: JSX.HTMLAttributes): JSX.Element -function CardContent(props: CardProps): JSX.Element -function CardFooter(props: CardProps): JSX.Element -``` - -#### Dialog Components - -```tsx -interface DialogProps { - children: JSX.Element - open?: boolean - onOpenChange?: (open: boolean) => void -} - -function Dialog(props: DialogProps): JSX.Element - -interface DialogTriggerProps { - children: JSX.Element - as?: Component -} - -function DialogTrigger(props: DialogTriggerProps): JSX.Element -function DialogContent(props: JSX.HTMLAttributes): JSX.Element -function DialogHeader(props: JSX.HTMLAttributes): JSX.Element -function DialogTitle(props: JSX.HTMLAttributes): JSX.Element -function DialogDescription(props: JSX.HTMLAttributes): JSX.Element -function DialogFooter(props: JSX.HTMLAttributes): JSX.Element -``` - -#### Drawer Components - -```tsx -interface DrawerProps { - children: JSX.Element - open?: boolean - onOpenChange?: (open: boolean) => void -} - -function Drawer(props: DrawerProps): JSX.Element -function DrawerTrigger(props: DialogTriggerProps): JSX.Element -function DrawerContent(props: JSX.HTMLAttributes): JSX.Element -function DrawerHeader(props: JSX.HTMLAttributes): JSX.Element -function DrawerTitle(props: JSX.HTMLAttributes): JSX.Element -function DrawerDescription(props: JSX.HTMLAttributes): JSX.Element -function DrawerFooter(props: JSX.HTMLAttributes): JSX.Element -``` - -### Utilities - -#### `cn` - Class Name Utility - -```tsx -function cn(...inputs: ClassValue[]): string -``` - -**Usage:** - -```tsx -import { cn } from "@effectify/solid-ui/lib/utils" - -function MyComponent(props: { class?: string }) { - return
-} -``` - -#### Validation Functions - -```tsx -function validateEmail(email: string): string | undefined -function validateRequired(value: any): string | undefined -function validateMinLength(value: string, min: number): string | undefined -function validateMaxLength(value: string, max: number): string | undefined -``` - -## @effectify/chat-solid - -### Components - -#### ChatProvider - -```tsx -interface ChatProviderProps { - userId: string - roomId: string - websocketUrl: string - options?: ChatOptions - children: JSX.Element -} - -interface ChatOptions { - reconnectAttempts?: number - reconnectDelay?: number - messageHistory?: number - heartbeatInterval?: number - typingTimeout?: number -} - -function ChatProvider(props: ChatProviderProps): JSX.Element -``` - -#### ChatRoom - -```tsx -interface ChatRoomProps extends JSX.HTMLAttributes { - showUserList?: boolean - showTypingIndicator?: boolean - messageLimit?: number -} - -function ChatRoom(props: ChatRoomProps): JSX.Element -``` - -#### ChatMessages - -```tsx -interface ChatMessagesProps extends JSX.HTMLAttributes { - messages: Message[] - renderMessage?: (message: Message) => JSX.Element - onLoadMore?: () => void - hasMore?: boolean -} - -function ChatMessages(props: ChatMessagesProps): JSX.Element -``` - -#### ChatInput - -```tsx -interface ChatInputProps extends JSX.HTMLAttributes { - onSendMessage: (message: { content: string; type: MessageType }) => void - disabled?: boolean - placeholder?: string - maxLength?: number - showEmojiPicker?: boolean - onTyping?: () => void - onStopTyping?: () => void -} - -function ChatInput(props: ChatInputProps): JSX.Element -``` - -#### ChatUserList - -```tsx -interface ChatUserListProps extends JSX.HTMLAttributes { - users: User[] - renderUser?: (user: User) => JSX.Element - onUserClick?: (user: User) => void -} - -function ChatUserList(props: ChatUserListProps): JSX.Element -``` - -### Hooks - -#### `useChatRoom` - -```tsx -function useChatRoom(): { - // State signals - messages: Accessor - users: Accessor - currentUser: Accessor - isConnected: Accessor - isLoading: Accessor - error: Accessor - - // Actions - sendMessage: (message: Partial) => void - joinRoom: (roomId: string) => void - leaveRoom: () => void - - // Typing indicators - typingUsers: Accessor - startTyping: () => void - stopTyping: () => void -} -``` - -#### `useChatMessages` - -```tsx -function useChatMessages(): { - messages: Accessor - sendMessage: (message: Partial) => void - editMessage: (id: string, updates: Partial) => void - deleteMessage: (id: string) => void - reactToMessage: (id: string, emoji: string) => void - loadMoreMessages: () => void - hasMore: Accessor - isLoading: Accessor -} -``` - -### Services - -#### ChatService - -```tsx -class ChatService { - static sendMessage(message: Partial): Effect - static joinRoom(roomId: string): Effect - static leaveRoom(roomId: string): Effect - static getMessageHistory(options: HistoryOptions): Effect - static getUserList(roomId: string): Effect -} - -interface HistoryOptions { - roomId: string - limit?: number - before?: Date - after?: Date -} -``` - -### Types - -#### Message - -```tsx -interface Message { - id: string - content: string - type: MessageType - userId: string - user: User - timestamp: Date - reactions?: Reaction[] - edited?: boolean - replyTo?: string -} - -type MessageType = "text" | "file" | "image" | "system" -``` - -#### User - -```tsx -interface User { - id: string - name: string - avatar?: string - status: UserStatus - lastSeen?: Date -} - -type UserStatus = "online" | "away" | "offline" -``` - -#### Reaction - -```tsx -interface Reaction { - emoji: string - users: string[] - count: number -} -``` - -#### ChatError - -```tsx -class ChatError extends Error { - readonly _tag = "ChatError" - constructor( - message: string, - readonly code: ChatErrorCode, - readonly cause?: unknown, - ) -} - -type ChatErrorCode = - | "CONNECTION_FAILED" - | "MESSAGE_SEND_FAILED" - | "AUTHENTICATION_FAILED" - | "ROOM_NOT_FOUND" - | "USER_NOT_FOUND" - | "PERMISSION_DENIED" -``` - ## SolidJS-Specific Patterns ### Reactive Queries @@ -399,25 +83,6 @@ const [user, { mutate, refetch }] = createResource( ) ``` -### Store Integration - -```tsx -import { createStore } from "solid-js/store" - -const [chatState, setChatState] = createStore({ - messages: [], - users: [], - currentRoom: null, -}) - -// Update store with Effect results -Effect.runPromise( - fetchMessages(roomId).pipe( - Effect.tap((messages) => Effect.sync(() => setChatState("messages", messages))), - ), -) -``` - ## Error Handling Patterns ### Effect Error Types @@ -444,60 +109,3 @@ class ValidationError extends EffectifyError { } } ``` - -### Error Boundaries - -```tsx -import { ErrorBoundary } from "solid-js" - -function App() { - return ( -
Error: {err.message}
}> - - - -
- ) -} -``` - -## Performance Optimization - -### Memoization - -```tsx -import { createMemo } from "solid-js" - -const filteredMessages = createMemo(() => { - const term = searchTerm() - return messages().filter((msg) => msg.content.toLowerCase().includes(term.toLowerCase())) -}) -``` - -### Lazy Loading - -```tsx -import { lazy } from "solid-js" - -const ChatRoom = lazy(() => import("./ChatRoom")) - -function App() { - return ( - Loading chat...
}> - - - ) -} -``` - -### Virtual Scrolling - -```tsx -import { createVirtualizer } from "@tanstack/solid-virtual" - -const virtualizer = createVirtualizer({ - count: messages().length, - getScrollElement: () => scrollElement, - estimateSize: () => 50, -}) -``` diff --git a/apps/react-router-example-e2e/src/tsconfig.json b/apps/react-router-example-e2e/src/tsconfig.json index aa92107c..ea997d5e 100644 --- a/apps/react-router-example-e2e/src/tsconfig.json +++ b/apps/react-router-example-e2e/src/tsconfig.json @@ -1,10 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "moduleResolution": "node10", "allowJs": true, "outDir": "../../dist/out-tsc", - "module": "commonjs", "types": ["cypress", "node"], "sourceMap": false, "rootDir": ".." diff --git a/apps/react-router-example-e2e/tsconfig.json b/apps/react-router-example-e2e/tsconfig.json index fe3f1096..dfbfe19d 100644 --- a/apps/react-router-example-e2e/tsconfig.json +++ b/apps/react-router-example-e2e/tsconfig.json @@ -2,8 +2,6 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node", "cypress"], - "module": "nodenext", - "moduleResolution": "node10", "target": "es2022", "lib": ["dom", "es2022"], "allowJs": false, diff --git a/apps/react-router-example/tests/unit/config/nx-cypress-target-contract.test.ts b/apps/react-router-example/tests/unit/config/nx-cypress-target-contract.test.ts index 2c71b734..5bb323c8 100644 --- a/apps/react-router-example/tests/unit/config/nx-cypress-target-contract.test.ts +++ b/apps/react-router-example/tests/unit/config/nx-cypress-target-contract.test.ts @@ -89,4 +89,41 @@ describe("Nx Cypress target contract", () => { expect(cypressConfig).not.toContain("webServerCommands") expect(cypressConfig).not.toContain("CYPRESS_MANUAL_SERVER") }) + + it("inherits the workspace NodeNext module contract in both e2e tsconfigs", () => { + type TsConfig = { + extends?: string + compilerOptions?: { + module?: string + moduleResolution?: string + } + } + + const baseConfig = readJson(resolve(workspaceRoot, "tsconfig.base.json")) + const e2eConfig = readJson(resolve(workspaceRoot, "apps/react-router-example-e2e/tsconfig.json")) + const e2eSourceConfig = readJson( + resolve(workspaceRoot, "apps/react-router-example-e2e/src/tsconfig.json"), + ) + const baseline = { + module: baseConfig.compilerOptions?.module, + moduleResolution: baseConfig.compilerOptions?.moduleResolution, + } + const inheritModuleOptions = (parent: typeof baseline, child: TsConfig) => ({ + module: child.compilerOptions?.module ?? parent.module, + moduleResolution: child.compilerOptions?.moduleResolution ?? parent.moduleResolution, + }) + const e2eModuleOptions = inheritModuleOptions(baseline, e2eConfig) + const e2eSourceModuleOptions = inheritModuleOptions(e2eModuleOptions, e2eSourceConfig) + + expect(baseline).toEqual({ module: "nodenext", moduleResolution: "nodenext" }) + expect(e2eConfig.extends).toBe("../../tsconfig.base.json") + expect(e2eModuleOptions).toEqual(baseline) + expect(e2eConfig.compilerOptions?.module).toBeUndefined() + expect(e2eConfig.compilerOptions?.moduleResolution).toBeUndefined() + + expect(e2eSourceConfig.extends).toBe("../tsconfig.json") + expect(e2eSourceModuleOptions).toEqual(baseline) + expect(e2eSourceConfig.compilerOptions?.module).toBeUndefined() + expect(e2eSourceConfig.compilerOptions?.moduleResolution).toBeUndefined() + }) }) diff --git a/docs/design/hatchet-design.md b/docs/design/hatchet-design.md deleted file mode 100644 index f29c593b..00000000 --- a/docs/design/hatchet-design.md +++ /dev/null @@ -1,764 +0,0 @@ -# Technical Design: @effectify/hatchet - -**Version**: 1.0.0\ -**Status**: Draft\ -**Created**: March 2026 - ---- - -## 1. Architecture Overview - -### 1.1 System Design - -The `@effectify/hatchet` package bridges Effect v4 with Hatchet SDK v1.19.0, enabling users to write Hatchet workflows as pure `Effect` computations. The architecture follows a layered pattern where each layer handles a specific concern: - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Hatchet Engine (External) │ -│ (Workflow execution, retries, UI) │ -└─────────────────────────────────────────────────────────────────────────────┘ - ▲ - │ HTTP/WebSocket - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ @effectify/hatchet │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Core │ │ Effectifier │ │ Workflow │ │ Logging │ │ -│ │ Config │ │ execute() │ │ Builder │ │ HatchetLog │ │ -│ │ Client │ │ ManagedRt │ │ task() │ │ withLogger │ │ -│ │ Context │ │ │ │ register │ │ │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ -│ │ │ │ │ │ -│ └────────────────┴────────────────┴────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────────────┐ │ -│ │ ServiceMap Layer Composition │ │ -│ │ ┌──────────────┐ ┌────────────────┐ ┌────────────────────┐ │ │ -│ │ │HatchetConfig │ │HatchetClient │ │HatchetStepContext │ │ │ -│ │ │ Service │ │ Service │ │ Service │ │ │ -│ │ └──────────────┘ └────────────────┘ └────────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────┘ - ▲ - │ Effect.provide() - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ User Application Code │ -│ ┌───────────────────────────────────────────────────────────────────────┐ │ -│ │ workflow({ name: "user-workflow" }) │ │ -│ │ .task({ name: "task1" }, Effect.gen(function*() { ... })) │ │ -│ │ .task({ name: "task2", parents: ["task1"] }, Effect.gen(...)) │ │ -│ └───────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 1.2 Data Flow - -The execution flow follows this sequence: - -``` -1. User defines workflow with EffectWorkflow builder -2. registerWorkflow() creates Hatchet workflow + tasks -3. Hatchet worker starts, listening for workflow triggers -4. Hatchet calls task function with (input, context) -5. effectifyTask converts Promise → Effect execution: - a. Inject HatchetStepContext as service - b. Run Effect with ManagedRuntime - c. Convert Success → return value - d. Convert Failure → throw (triggers Hatchet retry) -6. Logs via HatchetLogger sync to Hatchet UI -7. Parent task outputs accessible via ctx.parentOutput() -``` - ---- - -## 2. Effectifier Design - -### 2.1 Core Concept - -The **effectifier** is the bridge that converts `Effect` into a Hatchet-compatible Promise function `(input: unknown, ctx: HatchetContext) => Promise`. This is necessary because Hatchet expects async functions, not Effect computations. - -### 2.2 Effectifier Implementation - -```typescript -// packages/hatchet/src/effectifier/execute.ts - -import { Cause, Effect, ManagedRuntime, ServiceMap } from "effect" -import type { Context as HatchetContext } from "@hatchet-dev/typescript-sdk" -import { HatchetStepContext } from "../core/context" - -/** - * effectifyTask: Converts Effect to Hatchet-compatible Promise function - * - * Key behaviors: - * - Injects HatchetStepContext as a service - * - Uses ManagedRuntime to execute the Effect - * - Converts Success → return value - * - Converts Failure → throw Error (triggers Hatchet retries) - */ -export const effectifyTask = ( - effect: Effect.Effect, - runtime: ManagedRuntime.ManagedRuntime, -) => { - return async (input: unknown, ctx: HatchetContext): Promise => { - // Step 1: Provide HatchetStepContext as a service - const effectWithContext = Effect.provideService( - effect, - HatchetStepContext, - ctx, - ) - - // Step 2: Execute with ManagedRuntime - const exit = await runtime.runPromiseExit(effectWithContext) - - // Step 3: Convert result - if (exit._tag === "Success") { - return exit.value - } else { - // Failure → throw for Hatchet retry mechanism - const error = Cause.squash(exit.cause) - throw error instanceof Error ? error : new Error(String(error)) - } - } -} - -/** - * createEffectifierFromLayer: Factory that creates an effectifier - * from a Layer - * - * The layer provides all dependencies needed by the Effects - */ -export const createEffectifierFromLayer = ( - layer: Layer.Layer, -) => { - // Create persistent runtime for all tasks - const runtime = ManagedRuntime.make(layer) - - return (effect: Effect.Effect) => effectifyTask(effect, runtime) -} -``` - -### 2.3 Error Propagation Strategy - -The effectifier must convert Effect failures to thrown exceptions because Hatchet's retry mechanism works on thrown errors: - -| Effect Result | Hatchet Behavior | -| --------------------- | --------------------------------------------- | -| `Exit.success(value)` | Return `value` to workflow | -| `Exit.failure(cause)` | Throw error → Hatchet retries (if configured) | - -**Critical**: Not all errors should trigger retries. The design allows users to: - -- Use `Effect.fail()` for recoverable errors (triggers retry) -- Use `Effect.die()` for fatal errors (no retry) -- Configure retries per-task in the workflow definition - -### 2.4 Runtime Lifecycle - -The ManagedRuntime is created once per workflow registration and reused across all task executions: - -```typescript -// In registerWorkflow() -const runtime = ManagedRuntime.make(layer) - -// Each task gets the same runtime -wf.tasks.forEach((taskDef) => { - hatchetWorkflow.task({ - name: taskDef.options.name, - fn: effectifyTask(taskDef.effect, runtime), // Same runtime instance - }) -}) -``` - -**Important**: The runtime should be cleaned up when the worker stops. This will be handled in the worker lifecycle management. - ---- - -## 3. Workflow Builder Design - -### 3.1 EffectWorkflow Class - -The `EffectWorkflow` class provides a builder pattern for defining workflows: - -```typescript -// packages/hatchet/src/workflow/workflow.ts - -import { Effect } from "effect" -import type { TaskDefinition, TaskOptions, WorkflowOptions } from "./types" - -export class EffectWorkflow { - readonly tasks: TaskDefinition[] = [] - - constructor( - readonly options: WorkflowOptions, - readonly dependencies: R = undefined as R, - ) {} - - /** - * Adds a task to the workflow - * Accumulates dependencies via union types - */ - task( - options: TaskOptions, - effect: Effect.Effect, - ): EffectWorkflow { - this.tasks.push({ options, effect } as TaskDefinition) - return this as any - } -} - -export const workflow = (options: WorkflowOptions) => new EffectWorkflow(options) -``` - -### 3.2 Type Inference Flow - -The type system tracks accumulated dependencies through the builder: - -``` -workflow({ name: "my-workflow" }) - .task({ name: "task1" }, Effect<..., ..., Database>) // R = Database - .task({ name: "task2" }, Effect<..., ..., EmailService>) // R = Database | EmailService - .task({ name: "task3" }, Effect<..., ..., Logger>) // R = Database | EmailService | Logger -``` - -This enables: - -- Full type inference for all dependencies -- Compile-time error if a required service is missing -- Layer construction from accumulated types - -### 3.3 Task Options Mapping - -The SDK uses different terminology than the internal types: - -```typescript -// packages/hatchet/src/workflow/types.ts - -import type { RetryOpts, TaskConcurrency } from "@hatchet-dev/typescript-sdk" - -export interface TaskOptions { - readonly name: string - readonly timeout?: string // SDK: execution_timeout - readonly retries?: number // SDK: retries (RetryOpts) - readonly rateLimits?: Array<{ key: string; limit: number; duration: string }> - readonly concurrency?: TaskConcurrency[] - readonly parents?: string[] // DAG dependencies -} - -export interface WorkflowOptions { - readonly name: string - readonly description?: string - readonly version?: string - readonly sticky?: boolean - readonly concurrency?: TaskConcurrency[] -} -``` - -### 3.4 Registration Flow - -The `registerWorkflow` function orchestrates the entire registration: - -```typescript -// packages/hatchet/src/workflow/register.ts - -export const registerWorkflow = ( - workerName: string, - wf: EffectWorkflow, - layer: Layer.Layer, -): Effect.Effect => - Effect.gen(function*() { - // 1. Get Hatchet client - const hatchet = yield* HatchetClientService - - // 2. Create runtime for effect execution - const runtime = ManagedRuntime.make(layer) - - // 3. Create Hatchet workflow - const hatchetWorkflow = hatchet.workflow({ - name: wf.options.name, - ...(wf.options.description && { description: wf.options.description }), - ...(wf.options.version && { version: wf.options.version }), - }) - - // 4. Register each task - wf.tasks.forEach((taskDef) => { - hatchetWorkflow.task({ - name: taskDef.options.name, - fn: effectifyTask(taskDef.effect, runtime), - ...mapTaskOptions(taskDef.options), // Convert to SDK format - }) - }) - - // 5. Create and start worker - const worker = yield* Effect.tryPromise({ - try: () => hatchet.worker(workerName, { workflows: [hatchetWorkflow] }), - catch: (e) => new HatchetError({ message: "Failed to create worker", cause: e }), - }) - - yield* Effect.log( - `Workflow '${wf.options.name}' registered on worker '${workerName}'`, - ) - - yield* Effect.tryPromise({ - try: () => worker.start(), - catch: (e) => new HatchetError({ message: "Failed to start worker", cause: e }), - }) - }) -``` - ---- - -## 4. ServiceMap Integration - -### 4.1 Service Hierarchy - -Three core services form the foundation: - -``` -ServiceMap -├── HatchetConfig (static configuration) -│ └── { token, host, namespace } -├── HatchetClientService (Hatchet SDK client) -│ └── HatchetClient instance -└── HatchetStepContext (per-task execution context) - └── HatchetContext from SDK -``` - -### 4.2 HatchetConfig Service - -```typescript -// packages/hatchet/src/core/config.ts - -import { Config, Effect, Layer, Schema, ServiceMap } from "effect" - -const HatchetConfigSchema = Schema.Struct({ - token: Schema.String, - host: Schema.String.pipe(Schema.defaultTo("http://localhost:8080")), - namespace: Schema.optional(Schema.String), -}) - -type HatchetConfigType = Schema.Schema.Type - -/** - * HatchetConfig: Static configuration for Hatchet connection - * Uses ServiceMap.Service (NOT Context.Tag from v3) - */ -export class HatchetConfig extends ServiceMap.Service< - HatchetConfig, - HatchetConfigType ->()("HatchetConfig") {} - -export const HatchetConfigLayer = (config: HatchetConfigType) => Layer.succeed(HatchetConfig, config) - -export const HatchetConfigLayerFromEnv = ( - config: Config.Wrap, -): Layer.Layer => - Layer.effect(HatchetConfig)(Effect.map(Config.unwrap(config), (c) => c)) -``` - -### 4.3 HatchetClientService - -```typescript -// packages/hatchet/src/core/client.ts - -import { Data, Effect, Layer, ServiceMap } from "effect" -import { HatchetClient } from "@hatchet-dev/typescript-sdk" - -/** - * HatchetError: Tagged error for Hatchet-specific failures - */ -export class HatchetError extends Data.TaggedError( - "@effectify/hatchet/HatchetError", -)<{ - readonly message: string - readonly cause?: unknown -}> {} - -/** - * HatchetClientService: The SDK client instance - */ -export class HatchetClientService extends ServiceMap.Service< - HatchetClientService, - HatchetClient ->()("HatchetClient") {} - -/** - * HatchetClientLive: Creates client from config - */ -export const HatchetClientLive = Layer.effect(HatchetClientService)( - Effect.gen(function*() { - const config = yield* HatchetConfig - const hatchet = HatchetClient.init({ - token: config.token, - host_port: config.host, - }) - return hatchet - }), -) -``` - -### 4.4 HatchetStepContext - -```typescript -// packages/hatchet/src/core/context.ts - -import { Effect, ServiceMap } from "effect" -import type { Context as HatchetContext } from "@hatchet-dev/typescript-sdk" - -/** - * HatchetStepContext: Per-task execution context from Hatchet SDK - * - * Provides access to: - * - input: workflow input (property, not method in SDK v1) - * - parentOutput(taskRef): output from parent tasks - * - log(): write to Hatchet UI - * - logger: structured logging - */ -export class HatchetStepContext extends ServiceMap.Service< - HatchetStepContext, - HatchetContext ->()("HatchetStepContext") {} - -/** - * getHatchetInput: Helper to extract typed input from context - */ -export const getHatchetInput = () => Effect.map(HatchetStepContext, (ctx) => ctx.input as T) -``` - ---- - -## 5. Logging Architecture - -### 5.1 HatchetLogger Design - -The custom logger intercepts Effect.log() calls and forwards them to Hatchet: - -```typescript -// packages/hatchet/src/logging/hatchet-logger.ts - -import { Effect, Logger, Option, ServiceMap } from "effect" -import { HatchetStepContext } from "../core/context" - -/** - * HatchetLogger: Custom Effect Logger that forwards logs to Hatchet UI - * - * Flow: - * 1. Effect.log() is called in user code - * 2. Logger.make() receives the log entry - * 3. Check if HatchetStepContext exists in the fiber - * 4. If yes → forward to ctx.log() - * 5. Always output to console for development - */ -export const HatchetLogger = Logger.make(({ logLevel, message, context }) => { - const msg = typeof message === "string" ? message : String(message) - const formatted = `[${logLevel.label}] ${msg}` - - // Check if we're in a Hatchet task context - const hatchetCtxOpt = ServiceMap.getOption(context, HatchetStepContext) - - if (Option.isSome(hatchetCtxOpt)) { - // We're inside a task → forward to Hatchet - hatchetCtxOpt.value.log(formatted) - } - - // Always log to console - console.log(formatted) -}) - -/** - * withHatchetLogger: Wraps an Effect with the Hatchet logger - * - * Note: Logger.replace doesn't exist in v4 → use Effect.withLogger - */ -export const withHatchetLogger = ( - effect: Effect.Effect, -): Effect.Effect => Effect.withLogger(effect, HatchetLogger) -``` - -### 5.2 Log Level Mapping - -Effect log levels map to Hatchet: - -| Effect LogLevel | Hatchet Method | -| --------------- | --------------------------------- | -| Debug | `ctx.logger.debug()` | -| Info | `ctx.log()` / `ctx.logger.info()` | -| Warning | `ctx.logger.warn()` | -| Error | `ctx.logger.error()` | -| Fatal | `ctx.logger.error()` | - ---- - -## 6. Schema Integration - -### 6.1 Input Validation - -```typescript -// packages/hatchet/src/schema/get-validated-input.ts - -import { Effect, Schema } from "effect" -import { HatchetStepContext } from "../core/context" - -/** - * getValidatedInput: Extract and validate workflow input - * - * Uses Schema.decodeUnknown from the effect package - * (NOT @effect/schema - it's all in one package in v4) - */ -export const getValidatedInput = ( - schema: Schema.Schema, -): Effect.Effect => - Effect.gen(function*() { - const ctx = yield* HatchetStepContext - const rawInput = ctx.input - const decode = Schema.decodeUnknown(schema) - return yield* decode(rawInput) - }) -``` - ---- - -## 7. Testing Strategy - -### 7.1 Unit Testing Utilities - -```typescript -// packages/hatchet/src/testing/mock-context.ts - -import { Effect, Exit, ServiceMap } from "effect" -import { HatchetStepContext } from "../core/context" - -/** - * createMockStepContext: Creates a mock Hatchet context - * Matches SDK v1.19.0 interface - */ -export const createMockStepContext = (input?: unknown) => ({ - input: input ?? {}, - parentOutput: async () => null, - log: async (msg: string) => console.log(`[HATCHET] ${msg}`), - logger: { - info: async (msg: string) => console.info(`[INFO] ${msg}`), - debug: async (msg: string) => console.debug(`[DEBUG] ${msg}`), - warn: async (msg: string) => console.warn(`[WARN] ${msg}`), - error: async (msg: string) => console.error(`[ERROR] ${msg}`), - }, - workflowRunId: () => "test-run-id", - workflowName: () => "test-workflow", - taskName: () => "test-task", - retryCount: () => 0, -}) - -/** - * runTestTask: Execute an Effect task with mock context - * Returns Exit for detailed result inspection - */ -export const runTestTask = ( - effect: Effect.Effect, - layer: Layer.Layer, - mockContext?: any, -): Effect.Effect, never, R> => { - const ctx = mockContext ?? createMockStepContext() - - return Effect.gen(function*() { - const runtime = yield* Effect.runtime() - - return yield* Effect.provideService(effect, HatchetStepContext, ctx).pipe( - Effect.exit, - Effect.provideLayer(layer), - ) - }) -} -``` - -### 7.2 Integration Testing - -Integration tests require Docker Compose: - -```yaml -# packages/hatchet/tests/integration/docker-compose.yml -version: "3.8" - -services: - postgres-test: - image: postgres:16-alpine - environment: - POSTGRES_USER: hatchet - POSTGRES_PASSWORD: hatchet - POSTGRES_DB: hatchet - tmpfs: - - /var/lib/postgresql/data - - hatchet-test: - image: ghcr.io/hatchet-dev/hatchet:latest - environment: - - HATCHET_SERVER_TOKEN=test-token - - DATABASE_URL=postgresql://hatchet:hatchet@postgres-test:5432/hatchet - depends_on: - postgres-test: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] -``` - ---- - -## 8. Type Flow - -### 8.1 Input Type Propagation - -``` -User defines Schema - │ - ▼ -getValidatedInput(Schema) - │ - ▼ -Effect - │ - ▼ -Effect.gen yields validated input (type-safe) -``` - -### 8.2 ServiceMap Type Inference - -``` -workflow({ name: "wf" }) - .task(opts, Effect<..., ..., Database>) - .task(opts, Effect<..., ..., EmailService>) - │ - ▼ -EffectWorkflow - │ - ▼ -Layer - │ - ▼ -ManagedRuntime.make(layer) - │ - ▼ -Effectifier: (input, ctx) => Promise -``` - ---- - -## 9. Package Structure - -``` -packages/hatchet/ -├── project.json # Nx project config (follows prisma pattern) -├── package.json # Package manifest -├── tsconfig.json # TypeScript config -├── vitest.config.ts # Test config -├── src/ -│ ├── index.ts # Public exports -│ ├── core/ -│ │ ├── config.ts # HatchetConfig + Layers -│ │ ├── client.ts # HatchetClientService + HatchetError -│ │ └── context.ts # HatchetStepContext -│ ├── effectifier/ -│ │ ├── execute.ts # effectifyTask + createEffectifierFromLayer -│ │ └── types.ts # Internal types -│ ├── workflow/ -│ │ ├── workflow.ts # EffectWorkflow class + workflow() -│ │ ├── task.ts # task() function -│ │ ├── register.ts # registerWorkflow() -│ │ └── types.ts # TaskOptions, WorkflowOptions -│ ├── logging/ -│ │ └── hatchet-logger.ts # HatchetLogger + withHatchetLogger -│ ├── schema/ -│ │ └── get-validated-input.ts # getValidatedInput -│ └── testing/ -│ └── mock-context.ts # Test utilities -├── tests/ -│ ├── unit/ -│ │ ├── client.test.ts -│ │ ├── effectifier.test.ts -│ │ ├── logger.test.ts -│ │ └── workflow.test.ts -│ └── integration/ -│ ├── docker-compose.yml -│ └── workflow.test.ts -└── README.md -``` - ---- - -## 10. Key Design Decisions - -### 10.1 Effect v4 API Choices - -| Decision | Rationale | -| --------------------------------------- | ----------------------------------------- | -| `ServiceMap.Service` over `Context.Tag` | Context module doesn't exist in v4 | -| `ManagedRuntime.make(layer)` | `Effect.runtime()` doesn't exist in v4 | -| `Effect.withLogger(effect, logger)` | `Logger.replace` doesn't exist in v4 | -| `ServiceMap.getOption` | Context module doesn't exist | -| Schema from `effect` package | `@effect/schema` is deprecated/merged | - -### 10.2 Hatchet SDK v1.19.0 API Choices - -| Decision | Rationale | -| --------------------------- | ---------------------------------------- | -| `.task()` not `.step()` | SDK v1 uses task terminology | -| `ctx.input` property | SDK v1 has input as property, not method | -| `ctx.parentOutput(taskRef)` | Replaces deprecated `stepOutput()` | - -### 10.3 Error Handling Strategy - -- **Recoverable errors**: Use `Effect.fail()` → converted to thrown Error → Hatchet retries -- **Fatal errors**: Use `Effect.die()` or `Effect.exit()` → no retry -- **Tagged errors**: Use `HatchetError` for Hatchet-specific failures - ---- - -## 11. Risks and Mitigations - -| Risk | Likelihood | Impact | Mitigation | -| ---------------------------- | ---------- | ------ | -------------------------------------------- | -| SDK API changes | Medium | High | Pin to v1.19.0, verify on updates | -| Effect v4 breaking changes | Low | High | Use only verified APIs from effect-reference | -| Runtime memory leaks | Medium | Medium | Ensure runtime disposal in worker stop | -| Retry loop on startup errors | Medium | High | Use Effect.die() for fatal startup errors | - ---- - -## 12. Dependencies - -```json -{ - "dependencies": { - "@hatchet-dev/typescript-sdk": "^1.19.0" - }, - "peerDependencies": { - "effect": "catalog:" - }, - "devDependencies": { - "@effect/vitest": "catalog:", - "@types/node": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - } -} -``` - ---- - -## 13. Next Steps - -1. **Initialize package structure** following `@effectify/prisma` patterns -2. **Implement core services**: HatchetConfig, HatchetClientService, HatchetStepContext -3. **Build effectifier**: effectifyTask with ManagedRuntime integration -4. **Create workflow builder**: EffectWorkflow with task chaining -5. **Add logging**: HatchetLogger with Effect.withLogger -6. **Schema utilities**: getValidatedInput -7. **Testing utilities**: createMockStepContext, runTestTask -8. **Write unit tests** (>90% coverage) -9. **Docker Compose setup** for integration tests -10. **Integration tests** against real Hatchet - ---- - -_Document created: March 2026_ -_Version: 1.0.0_ diff --git a/docs/prds/CHAT-original-idea-effectify-hatchet.md b/docs/prds/CHAT-original-idea-effectify-hatchet.md deleted file mode 100644 index c7b5ef75..00000000 --- a/docs/prds/CHAT-original-idea-effectify-hatchet.md +++ /dev/null @@ -1,709 +0,0 @@ -¡Totalmente viable y de hecho es una excelente idea! Integrar un SDK basado en Promesas como el de Hatchet dentro del ecosistema de Effect es uno de los casos de uso más comunes e ideales. Te permite ganar tipado estricto de errores, inyección de dependencias limpia y un manejo de concurrencia mucho más robusto. -​Aquí tienes un Proof of Concept (PoC) de cómo se vería @effectify/hatchet. Lo dividiremos en tres partes: la definición del servicio (para inyección de dependencias), los wrappers de las acciones asíncronas, y el adaptador para que Hatchet pueda ejecutar tus steps definidos con Effect. -​1. Definición del Servicio y Errores -​Primero, envolvemos la instancia de Hatchet en un Context.Tag para poder inyectarla en nuestra aplicación, y definimos un tipo de error personalizado. - -import { Effect, Context, Layer, Data } from "effect"; -import { Hatchet } from "@hatchet-dev/typescript-sdk"; - -// Definimos un error tipado para nuestro wrapper -export class HatchetError extends Data.TaggedError("HatchetError")<{ -  readonly message: string; -  readonly cause?: unknown; -}> {} - -// Creamos el Tag para inyectar el cliente de Hatchet -export class HatchetClient extends Context.Tag("@effectify/hatchet/Client")< -  HatchetClient, -  Hatchet - -> () {} - -// Capa (Layer) para proveer el cliente en tu aplicación -export const HatchetLive = Layer.succeed( -  HatchetClient, -  Hatchet.init() // Inicialización real del SDK de Hatchet -); - -2. Wrapper de Acciones (Llamadas a la API) - ​Aquí envolvemos las llamadas a la API de Hatchet (que devuelven Promesas) usando Effect.tryPromise. Esto convierte las excepciones no controladas de las Promesas en errores tipados (HatchetError) en el canal de errores de Effect. - -// Wrapper para ejecutar un workflow desde tu app Effect -export const runWorkflow = (workflowName: string, input: any) => -  Effect.gen(function* () { -    // Obtenemos el cliente inyectado -    const hatchet = yield* HatchetClient; - -    // Envolvemos la Promesa en un Effect -    return yield* Effect.tryPromise({ -      try: () => hatchet.admin.runWorkflow(workflowName, input), -      catch: (error) => new HatchetError({ -        message: `Error al ejecutar workflow ${workflowName}`, -        cause: error -      }), -    }); -  }); - -3. Adaptador para Workflows (El "Effectifier") - ​Hatchet espera que los steps de un workflow sean funciones asíncronas (Promise). Para que tu app sea "nativa en Effect", quieres escribir tus steps devolviendo Effect. Necesitamos una función que traduzca tu Effect de vuelta a una Promise que Hatchet pueda entender al registrar el worker. - -import { Context as HatchetContext } from "@hatchet-dev/typescript-sdk"; - -/** - * Convierte un step escrito en Effect en una función async compatible con Hatchet. - * Nota: Para este PoC asumimos que el Effect no tiene requerimientos (never). -*/ -export const effectifyStep = ( -  effectStep: (ctx: HatchetContext) => Effect.Effect -) => { -  return async (ctx: HatchetContext): Promise => { -    // Convertimos el Effect en una Promesa para que Hatchet lo ejecute -    return await Effect.runPromise(effectStep(ctx)); -  }; -};[10:06 PM]¡Totalmente viable y de hecho es una excelente idea! Integrar un SDK basado en Promesas como el de Hatchet dentro del ecosistema de Effect es uno de los casos de uso más comunes e ideales. Te permite ganar tipado estricto de errores, inyección de dependencias limpia y un manejo de concurrencia mucho más robusto. -Aquí tienes un Proof of Concept (PoC) de cómo se vería @effectify/hatchet. Lo dividiremos en tres partes: la definición del servicio (para inyección de dependencias), los wrappers de las acciones asíncronas, y el adaptador para que Hatchet pueda ejecutar tus steps definidos con Effect. - -1. Definición del Servicio y Errores - Primero, envolvemos la instancia de Hatchet en un Context.Tag para poder inyectarla en nuestra aplicación, y definimos un tipo de error personalizado. - import { Effect, Context, Layer, Data } from "effect"; - import { Hatchet } from "@hatchet-dev/typescript-sdk"; - -// Definimos un error tipado para nuestro wrapper -export class HatchetError extends Data.TaggedError("HatchetError")<{ -  readonly message: string; -  readonly cause?: unknown; -}> {} - -// Creamos el Tag para inyectar el cliente de Hatchet -export class HatchetClient extends Context.Tag("@effectify/hatchet/Client")< -  HatchetClient, -  Hatchet - -> () {} - -// Capa (Layer) para proveer el cliente en tu aplicación -export const HatchetLive = Layer.succeed( -  HatchetClient, -  Hatchet.init() // Inicialización real del SDK de Hatchet -); - -2. Wrapper de Acciones (Llamadas a la API) - Aquí envolvemos las llamadas a la API de Hatchet (que devuelven Promesas) usando Effect.tryPromise. Esto convierte las excepciones no controladas de las Promesas en errores tipados (HatchetError) en el canal de errores de Effect. - // Wrapper para ejecutar un workflow desde tu app Effect - export const runWorkflow = (workflowName: string, input: any) => -   Effect.gen(function* () { -     // Obtenemos el cliente inyectado -     const hatchet = yield* HatchetClient; - -    // Envolvemos la Promesa en un Effect -    return yield* Effect.tryPromise({ -      try: () => hatchet.admin.runWorkflow(workflowName, input), -      catch: (error) => new HatchetError({ -        message: `Error al ejecutar workflow ${workflowName}`, -        cause: error -      }), -    }); -  }); - -3. Adaptador para Workflows (El "Effectifier") - Hatchet espera que los steps de un workflow sean funciones asíncronas (Promise). Para que tu app sea "nativa en Effect", quieres escribir tus steps devolviendo Effect. Necesitamos una función que traduzca tu Effect de vuelta a una Promise que Hatchet pueda entender al registrar el worker. - import { Context as HatchetContext } from "@hatchet-dev/typescript-sdk"; - -/** - * Convierte un step escrito en Effect en una función async compatible con Hatchet. - * Nota: Para este PoC asumimos que el Effect no tiene requerimientos (never). -*/ -export const effectifyStep = ( -  effectStep: (ctx: HatchetContext) => Effect.Effect -) => { -  return async (ctx: HatchetContext): Promise => { -    // Convertimos el Effect en una Promesa para que Hatchet lo ejecute -    return await Effect.runPromise(effectStep(ctx)); -  }; -}; - -4. Ejemplo de Uso: Tu App Nativa en Effect - Así es como se vería el código de tu aplicación consumiendo tu librería @effectify/hatchet. - import { Effect } from "effect"; - // Importaríamos desde "@effectify/hatchet" - // import { HatchetClient, HatchetLive, runWorkflow, effectifyStep } from "@effectify/hatchet"; - -// 1. Definimos un step nativo en Effect -const myEffectStep = (ctx: any) => -  Effect.gen(function* () { -    yield* Effect.log(`Iniciando step con input: ${JSON.stringify(ctx.workflowInput())}`); - -    // Aquí puedes usar otras bondades de Effect, como delays, retries, etc. -    yield* Effect.sleep("2 seconds"); - -    return { success: true, processedAt: new Date().toISOString() }; -  }); - -// 2. Registramos el worker (generalmente esto se hace al inicio de la app) -const startWorker = Effect.gen(function* () { -  const hatchet = yield* HatchetClient; -  const worker = hatchet.worker("effect-worker"); - -  // Usamos nuestro wrapper para adaptar el step -  yield* Effect.sync(() => { -    worker.registerWorkflow({ -      id: "my-effect-workflow", -      description: "Workflow de prueba nativo en Effect", -      on: { event: "trigger:effect-workflow" }, -      steps: [ -        { -          name: "step-1", -          // Adaptamos nuestro Effect a la firma que espera el SDK de Hatchet -          run: effectifyStep(myEffectStep), -        }, -], -    }); -  }); - -  yield* Effect.log("Iniciando worker de Hatchet..."); -  yield* Effect.tryPromise({ -    try: () => worker.start(), -    catch: (err) => new HatchetError({ message: "Error al iniciar worker", cause: err }) -  }); -}); - -// 3. Ejecutamos un workflow desde algún lugar de tu app -const mainProgram = Effect.gen(function* () { -  yield* Effect.log("Disparando workflow..."); - -  const result = yield* runWorkflow("my-effect-workflow", { -    message: "Hola desde Effect!" -  }); - -  yield* Effect.log(`Workflow disparado con ID: ${result.workflowRunId}`); -}); - -// 4. Unimos todo y proveemos las dependencias -const runnable = Effect.provide( -  Effect.all([startWorker, mainProgram], { concurrency: "unbounded" }), -  HatchetLive -); - -// Ejecutamos la aplicación -// Effect.runPromise(runnable); - -Puntos clave de este PoC: - * Aislamiento de Promesas: Tu lógica de negocio principal se mantiene pura trabajando con Effect, y solo tocas Promesas en los bordes (cuando llamas a Hatchet o cuando Hatchet llama a tu worker). - * Manejo de Errores Tipados: Si Hatchet falla al disparar un workflow, obtienes un HatchetError manejable mediante Effect.catchAll o Effect.catchTag. - * Inyección Limpia: Gracias a HatchetClient (el Context.Tag), puedes simular fácilmente el SDK en tus tests proporcionando un Layer mockeado en lugar del HatchetLive. -¿Te gustaría que profundicemos en cómo pasar un Runtime de Effect dentro de effectifyStep para que tus steps puedan acceder a otros servicios inyectados (como una base de datos o un logger personalizado) en lugar de estar limitados a dependencias never?[10:06 PM]Si podrías profundizar más y como Hatchet sabría si hay un error y hacer un retry com hatchet si hacemos el catch del error con Effect de lado de la app ? Hatcher creo que detecta los errores throw quiero manejarlo nativamente con Effect -[10:06 PM]Es una excelente pregunta y toca el corazón de cómo integrar dos paradigmas distintos: el manejo de errores funcional de Effect y el sistema de reintentos basado en excepciones de Hatchet. -Para responder a tu duda: "Manejarlo nativamente en Effect" no significa ocultarle el error a Hatchet. -Si dentro de tu step usas Effect.catchAll y devuelves un resultado exitoso, Hatchet dirá "¡Genial, el step funcionó!" y pasará al siguiente paso sin hacer retry. Para que Hatchet sepa que debe reintentar, el Effect debe terminar en un estado de fallo (Failure). -La magia ocurre en nuestro wrapper. Necesitamos hacer dos cosas: - * Extraer tu Effect usando un Runtime (para que puedas inyectar dependencias como bases de datos en tus steps). - * Evaluar el resultado del Effect: si es un éxito, devolver la Promesa resuelta; si es un fallo, traducirlo a un throw clásico que Hatchet entienda para disparar su retry. -Aquí tienes cómo evolucionaríamos el código para lograr exactamente esto. - -1. El "Effectifier" Avanzado (Manejando Errores y Dependencias) - En lugar de usar Effect.runPromise a secas, usaremos Runtime.runPromiseExit. Esto nos da el control absoluto sobre el ciclo de vida del Effect antes de entregárselo a Hatchet. - import { Effect, Runtime, Cause } from "effect"; - import { Context as HatchetContext } from "@hatchet-dev/typescript-sdk"; - -/** - * Fábrica para crear nuestro adaptador de steps, inyectándole el Runtime de tu app. - * Esto permite que tus steps tengan dependencias (R) y no estén limitados a 'never'. -*/ -export const createHatchetEffectifier = (runtime: Runtime.Runtime) => { -  // Retornamos la función que adaptará cada step individual -  return ( -    effectStep: (ctx: HatchetContext) => Effect.Effect -  ) => { -    return async (ctx: HatchetContext): Promise => { - -      // Ejecutamos el Effect y capturamos su "Exit" (Éxito o Fallo) -      const exit = await Runtime.runPromiseExit(runtime)(effectStep(ctx)); - -      if (exit._tag === "Success") { -        // Todo salió bien en Effect, le pasamos el valor a Hatchet -        return exit.value; -      } else { -        // El Effect falló (ya sea un error tipado E o un defecto inesperado). -        // Usamos Cause.squash para aplanar el error de Effect a algo manejable. -        const error = Cause.squash(exit.cause); - -        // ¡Aquí está la magia para Hatchet! Lanzamos un throw nativo. -        // Esto le dice a Hatchet: "Este step falló, aplica tu política de retries". -        throw error instanceof Error ? error : new Error(String(error)); -      } -    }; -  }; -}; - -2. Creando un Step con Errores y Dependencias - Ahora vamos a crear un step que simule interactuar con una base de datos. Usaremos los canales de Effect para fallar a propósito y ver cómo el wrapper hace su trabajo. - import { Context, Layer } from "effect"; - -// Simulamos un servicio de Base de Datos -export class Database extends Context.Tag("Database")< -  Database, -  { queryUser: (id: string) => Effect.Effect } - -> () {} - -export const DatabaseLive = Layer.succeed(Database, { -  queryUser: (id) => Effect.gen(function* () { -    // Simulamos un fallo aleatorio de red para provocar un retry en Hatchet -    const success = Math.random() > 0.5; -    if (!success) { -      yield* Effect.fail(new Error("Timeout en la base de datos")); -    } -    return { id, name: "Usuario Effect" }; -  }) -}); - -// Nuestro Step 100% nativo en Effect -const fetchUserStep = (ctx: HatchetContext) => -  Effect.gen(function* () { -    const input = ctx.workflowInput(); -    const db = yield* Database; // Usamos nuestra dependencia inyectada - -    yield* Effect.log("Intentando obtener usuario..."); - -    // Si queryUser falla, el flujo se interrumpe aquí y el Effect -    // termina en Failure. El "Effectifier" lo atrapará y hará el throw para Hatchet. -    const user = yield* db.queryUser(input.userId); - -    yield* Effect.log("Usuario obtenido con éxito"); -    return user; -  }); - -3. Uniendo Todo: Registrando el Worker con el Runtime - Para que tu fetchUserStep pueda acceder a la Database, necesitamos crear el Runtime de Effect y pasárselo a nuestro createHatchetEffectifier. - const startApp = Effect.gen(function* () { -   const hatchet = yield* HatchetClient; // Asumiendo el Tag del ejemplo anterior - -  // 1. Obtenemos el runtime actual que ya tiene inyectadas todas tus capas (Database, etc.) -  const runtime = yield* Effect.runtime(); - -  // 2. Creamos nuestra función adaptadora inyectándole el runtime -  const effectifyStep = createHatchetEffectifier(runtime); - -  const worker = hatchet.worker("effect-worker"); - -  yield* Effect.sync(() => { -    worker.registerWorkflow({ -      id: "fetch-user-workflow", -      description: "Workflow con retries y dependencias", -      on: { event: "trigger:fetch-user" }, -      steps: [ -        { -          name: "fetch-user", -          // Adaptamos nuestro Effect. ¡Tendrá acceso a la Database! -          // Si falla, Hatchet lo reintentará según su configuración por defecto. -          run: effectifyStep(fetchUserStep), -        }, -], -    }); -  }); - -  yield* Effect.tryPromise(() => worker.start()); -}); - -// Proveemos las capas e iniciamos -const runnable = Effect.provide(startApp, DatabaseLive); -// Effect.runPromise(runnable); - -Resumen del Flujo de Errores - * Si ocurre un error de red (Effect.fail): Tu código Effect se detiene. El exit._tag será "Failure". Nuestro wrapper lanza un throw. Hatchet atrapa el throw, marca el intento como fallido y programa el retry. - * Si el error es crítico y NO quieres que Hatchet reintente: Podrías manejarlo dentro de tu Effect usando Effect.catchAll y devolviendo un objeto que indique fallo en la lógica de negocio (ej. return { status: "FATAL", message: "..." }). Como no hay un throw, Hatchet lo tomará como un step "exitoso" y pasará al siguiente step, donde puedes evaluar ese status. -¿Te gustaría que diseñemos un sistema para tipar y extraer el contexto de Hatchet (por ejemplo, el input o los resultados de steps anteriores) usando el Context nativo de Effect en lugar de pasar la variable ctx como parámetro a la función?[10:06 PM]Buenísima idea procede -[10:07 PM]¡Excelente! Vamos a llevar el PoC al siguiente nivel. -El problema de pasar la variable ctx como parámetro a la función es que rompe un poco la filosofía de Effect. En el ecosistema de Effect, si una pieza de código necesita acceder a información contextual (como el input de un request, o en este caso, el contexto del workflow de Hatchet), lo ideal es declararlo como un requerimiento en el entorno (R) del Effect. -De esta manera, la firma de tu step queda completamente limpia de parámetros de infraestructura. -Aquí tienes cómo construiríamos este sistema de inyección dinámica. - -1. El Tag del Contexto de Hatchet - Primero, creamos un Context.Tag específico para almacenar el contexto que Hatchet nos entrega en cada ejecución de un step. - import { Context, Effect } from "effect"; - import { Context as HatchetContext } from "@hatchet-dev/typescript-sdk"; - -// Definimos el Tag. -// Nota: En una librería real, usaríamos genéricos -// para tipar fuertemente lo que devuelve ctx.workflowInput(). -export class HatchetStepContext extends Context.Tag("@effectify/hatchet/StepContext")< -  HatchetStepContext, -  HatchetContext - -> () {} - -2. El "Effectifier" de Inyección Dinámica - Ahora, actualizamos nuestra función fábrica. En lugar de aceptar una función que recibe ctx, ahora aceptará directamente un Effect que requiere el HatchetStepContext. - El "Effectifier" se encargará de atrapar el ctx que envía Hatchet en tiempo de ejecución y lo inyectará (Effect.provideService) en tu Effect justo antes de ejecutarlo. - import { Runtime, Cause } from "effect"; - -export const createHatchetEffectifier = (runtime: Runtime.Runtime) => { -  return ( -    // El step ya no es una función. Es un Effect puro que requiere -    // el contexto de Hatchet además de las dependencias de tu app (R) -    effectStep: Effect.Effect -  ) => { -    return async (ctx: HatchetContext): Promise => { - -      // 1. Inyectamos el contexto específico de esta ejecución de Hatchet -      const stepWithContext = Effect.provideService( -        effectStep, -        HatchetStepContext, -        ctx -      ); - -      // 2. Ejecutamos el Effect fusionado con el Runtime de tu app -      const exit = await Runtime.runPromiseExit(runtime)(stepWithContext); - -      // 3. Manejamos el resultado (el mismo mecanismo de retries de antes) -      if (exit._tag === "Success") { -        return exit.value; -      } else { -        const error = Cause.squash(exit.cause); -        throw error instanceof Error ? error : new Error(String(error)); -      } -    }; -  }; -}; - -3. El Step 100% Nativo y Limpio - Mira cómo queda tu lógica de negocio ahora. Tu step ya no es una función que recibe parámetros raros; es simplemente una descripción de un programa (Effect) que pide lo que necesita. - // Simulamos el Tag de la base de datos del ejemplo anterior - // import { Database } from "./mis-servicios"; - -export const fetchUserStep = Effect.gen(function* () { -  // Extraemos el contexto de Hatchet de forma nativa -  const ctx = yield* HatchetStepContext; - -  // Extraemos otras dependencias -  const db = yield* Database; - -  // Obtenemos el input tipado del workflow -  const input = ctx.workflowInput(); -  yield* Effect.log(`Buscando usuario con ID: ${input.userId}`); - -  // Ejecutamos la lógica que puede fallar (y disparar el retry de Hatchet) -  const user = yield* db.queryUser(input.userId); - -  return user; -}); - -Por qué este enfoque es superior: - * Firmas Limpias: El tipado te dice exactamente qué necesita tu step para funcionar (Database | HatchetStepContext). No hay argumentos ocultos. - * Composición: Puedes extraer lógica común a funciones auxiliares. Por ejemplo, podrías crear una función const getUserId = Effect.map(HatchetStepContext, ctx => ctx.workflowInput().userId) y usarla en cualquier parte de tu pipeline sin tener que pasar ctx en cascada. - * Testing Perfecto: Para hacer un test unitario de fetchUserStep, no necesitas levantar un worker de Hatchet. Solo usas Effect.provideService(HatchetStepContext, mockCtx) y listo. -El siguiente nivel: Hatchet tiene una característica excelente donde puedes hacer ctx.log("mensaje") para que los logs aparezcan en su UI en tiempo real. ¿Te gustaría que hagamos un Logger personalizado de Effect para que cuando tú escribas yield* Effect.log("Hola"), este se envíe automáticamente a la UI de Hatchet usando ese mismo ctx inyectado?[10:07 PM]También excelente idea procede -[10:07 PM]¡Manos a la obra! Esta es una de las integraciones más elegantes que puedes hacer, porque te permite mantener tu código completamente agnóstico de Hatchet, mientras aprovechas su UI de observabilidad. -En Effect, cuando usas Effect.log("Hola"), por debajo el sistema envía ese mensaje a todos los Loggers que estén registrados. Los Loggers en Effect tienen acceso al Context (las dependencias) del Fiber (el hilo ligero) que ejecutó el log. -Sabiendo esto, podemos crear un Logger personalizado que intercepte el mensaje, busque si existe el HatchetStepContext en el entorno, y si lo encuentra, lo envíe a la UI de Hatchet. - -1. Creando el Logger de Hatchet - Vamos a usar Logger.make para definir cómo queremos procesar los logs. - import { Logger, Context, Option, Layer } from "effect"; - // Importamos el tag que creamos en el paso anterior - // import { HatchetStepContext } from "./context"; - -export const HatchetLogger = Logger.make(({ logLevel, message, context }) => { -  // 1. Extraemos el mensaje base -  const msg = typeof message === "string" ? message : String(message); - -  // 2. Buscamos el contexto de Hatchet dentro del Fiber actual -  const hatchetCtxOpt = Context.getOption(context, HatchetStepContext); - -  if (Option.isSome(hatchetCtxOpt)) { -    // 3. ¡Bingo! Estamos dentro de un step de Hatchet. -    // Usamos el método nativo de Hatchet para enviar el log a su UI. -    const ctx = hatchetCtxOpt.value; - -    // Hatchet soporta ctx.log(). Le añadimos el nivel de severidad (INFO, ERROR, etc.) -    ctx.log(`[${logLevel.label}] ${msg}`); -  } - -  // 4. Mantenemos el log local en consola para el desarrollo -  // (En producción podrías omitir esto o usar un logger JSON) -  console.log(`[${logLevel.label}] ${msg}`); -}); - -// Creamos una Capa (Layer) que reemplaza el logger por defecto de Effect por el nuestro -export const HatchetLoggerLive = Logger.replace( -  Logger.defaultLogger, -  HatchetLogger -); - -2. Actualizando tu Step (¡Cero cambios!) - Lo hermoso de este enfoque es que tu lógica de negocio no cambia en absoluto. Usas Effect.log como lo harías normalmente en cualquier aplicación Effect. - export const processPaymentStep = Effect.gen(function* () { -   const ctx = yield* HatchetStepContext; -   const input = ctx.workflowInput(); - -  // Este log aparecerá automáticamente en tu terminal -  // ¡Y EN LA UI DE HATCHET EN TIEMPO REAL! -  yield* Effect.log(`Iniciando procesamiento de pago para la orden: ${input.orderId}`); - -  // ... lógica compleja ... - -  if (input.amount > 10000) { -    // También soporta distintos niveles de log -    yield* Effect.logWarning("El monto es inusualmente alto, marcando para revisión"); -  } - -  yield* Effect.log("Pago procesado con éxito"); -  return { status: "success" }; -}); - -3. Inyectando el Logger en tu Aplicación - Para que el Logger empiece a funcionar, solo tienes que proveer su Layer al momento de arrancar tu aplicación o tu Worker. - import { Effect } from "effect"; - -const startWorkerApp = Effect.gen(function* () { -  const hatchet = yield* HatchetClient; -  const runtime = yield* Effect.runtime(); // + Otras deps -  const effectifyStep = createHatchetEffectifier(runtime); - -  const worker = hatchet.worker("payment-worker"); - -  yield* Effect.sync(() => { -    worker.registerWorkflow({ -      id: "payment-workflow", -      description: "Workflow de pagos con logs sincronizados", -      on: { event: "trigger:payment" }, -      steps: [ -        { -          name: "process-payment", -          run: effectifyStep(processPaymentStep), -        }, -], -    }); -  }); - -  yield* Effect.tryPromise(() => worker.start()); -}); - -// Aquí es donde ocurre la magia final. -// Proveemos el Logger a toda la aplicación. -const runnable = Effect.provide(startWorkerApp, HatchetLoggerLive); - -// Effect.runPromise(runnable); - -El resultado -Con esta arquitectura lograste: - * Separación de responsabilidades: Tus steps no saben que están enviando logs a internet; solo usan herramientas nativas de Effect. - * Observabilidad dual: Tienes logs en la consola local (para debuggear rápido) y en el dashboard de Hatchet (para monitoreo del sistema). - * Código limpio: Sin pasar ctx a funciones anidadas solo para poder loggear un evento. -Para cerrar el círculo de esta arquitectura Effect-native, el único punto débil que nos queda es que ctx.workflowInput() devuelve any, lo cual es peligroso. -¿Te gustaría que te muestre cómo integrar @effect/schema en nuestro "Effectifier" para que el input del workflow sea validado y parseado estrictamente antes de que tu step empiece a ejecutarse?[10:07 PM]Si adelante muestrame -[10:08 PM]¡Perfecto! Este es el toque final para hacer que la librería sea un wrapper robusto, de nivel empresarial. -El principal problema de ctx.workflowInput() es que te devuelve un tipo any. Si cambias la estructura del evento que dispara el workflow, pero olvidas actualizar el step, el código fallará en tiempo de ejecución (o peor, causará bugs silenciosos). -Con @effect/schema, validamos la estructura exacta de los datos antes de que empiece a correr tu lógica de negocio. Si los datos vienen mal formados, el step falla inmediatamente y rechaza la ejecución. - -1. El Extractor Validado (Tu nueva herramienta) - Vamos a crear una función de utilidad en tu librería @effectify/hatchet que se encargue de leer el contexto de Hatchet y pasarlo por un esquema de validación. - import { Effect } from "effect"; - import * as Schema from "@effect/schema/Schema"; - // import { HatchetStepContext } from "./context"; - -/** - * Lee el input del workflow desde el contexto inyectado - * y lo valida contra un esquema de @effect/schema. -_/ -export const getValidatedInput = (schema: Schema.Schema) => -  Effect.gen(function_ () { -    // 1. Obtenemos el contexto (si no está, Effect pedirá inyectarlo) -    const ctx = yield* HatchetStepContext; -    const rawInput = ctx.workflowInput(); - -    // 2. Creamos el decodificador basado en el esquema -    const decode = Schema.decodeUnknown(schema); - -    // 3. Ejecutamos la validación. -    // Si falla, el Effect se interrumpe automáticamente con un ParseError. -    return yield* decode(rawInput); -  }); - -2. Definiendo el Esquema de tu Input - En tu aplicación, en lugar de confiar ciegamente en lo que llega, describes exactamente cómo debe ser la forma de los datos usando @effect/schema. - // schemas.ts - import * as Schema from "@effect/schema/Schema"; - -// Definimos un esquema estricto para el pago -export const PaymentInputSchema = Schema.Struct({ -  orderId: Schema.String, -  // Podemos añadir validaciones de negocio directamente al esquema: -  amount: Schema.Number.pipe(Schema.positive()), -  currency: Schema.Literal("USD", "EUR", "MXN"), -  customerId: Schema.String -}); - -// Opcional: Extraemos el tipo de TypeScript para usarlo en otras funciones -export type PaymentInput = Schema.Schema.Type; - -3. El Step Final: Seguro y Tipado - Mira cómo se transforma tu lógica de negocio. Es increíblemente limpio y 100% Type-Safe. - // import { getValidatedInput } from "@effectify/hatchet"; - // import { PaymentInputSchema } from "./schemas"; - -export const processPaymentStep = Effect.gen(function* () { -  // Magia pura: Extrae, valida y tipa el input en una sola línea. -  const input = yield* getValidatedInput(PaymentInputSchema); - -  // ¡A partir de aquí, TypeScript sabe exactamente qué es 'input'! -  // Si intentas hacer input.monto (incorrecto) en lugar de input.amount, el editor se quejará. -  yield* Effect.log( -`Procesando cobro de ${input.amount} ${input.currency} para la orden ${input.orderId}` -  ); - -  // Si llegamos a esta línea, garantizamos que amount > 0 y currency es válido. -  // ... tu lógica de cobro con base de datos o API externa ... - -  return { status: "success", receiptId: "rec_12345" }; -}); - -¿Qué pasa si el Input es inválido? (Manejo de Errores) -Si disparas el workflow y omites el customerId, el Schema.decodeUnknown lanzará un error tipo ParseError. -Como vimos en los pasos anteriores, nuestro "Effectifier" atrapa cualquier error en la capa del Runtime. Así que tomará ese ParseError, lo convertirá en una excepción y se la enviará a Hatchet. En tu panel de Hatchet verás exactamente por qué falló el step (ej. "Missing property customerId"), deteniendo la ejecución antes de causar problemas de consistencia en tu base de datos. -Resumen del Stack @effectify/hatchet -Hasta este punto, hemos construido las bases de una librería increíblemente poderosa: - * HatchetClient: Inyección limpia del SDK de Hatchet. - * createHatchetEffectifier: El puente que traduce Effect a Promise y maneja los fallos para que Hatchet haga los retries. - * HatchetStepContext: Inyección dinámica del contexto sin pasar variables por parámetros. - * HatchetLogger: Sincronización transparente de los logs nativos de Effect con la UI de Hatchet. - * getValidatedInput: Parseo estricto y seguro en tiempo de ejecución de los eventos entrantes. -¿Te gustaría que diseñemos un patrón "Builder" o "Router"? Esto nos serviría para que registrar los workflows en tu worker no requiera tanto "boilerplate" de Hatchet, permitiéndote encadenar steps de manera más declarativa y puramente al estilo de Effect.[10:08 PM]Me parece bien el patrón builder -[10:08 PM]¡Excelente! Llegamos a la cereza del pastel. -En Effect, cuando construyes una librería, quieres que la API pública sea declarativa y que maneje la composición de dependencias (R) de forma automática. El problema actual con el registro de Hatchet es que te obliga a mezclar la configuración del worker con la definición de tus steps, y además tienes que llamar a effectifyStep manualmente para cada uno. -Vamos a crear un módulo Workflow usando el patrón Builder (muy similar a cómo funciona Router en @effect/platform o @effect/rpc). Esto nos permitirá encadenar métodos usando pipe e inferir todas las dependencias requeridas. - -1. El Módulo Builder (Workflow) - Primero, definimos una estructura inmutable que irá acumulando la configuración y los steps, manteniendo un registro estricto de los tipos y dependencias (R). - import { Effect, pipe } from "effect"; - // import { HatchetStepContext } from "./context"; - -// Definición interna de un step no evaluado -interface StepDefinition { -  name: string; -  effect: Effect.Effect; -} - -// Nuestra estructura principal inmutable -export class HatchetWorkflow { -  constructor( -    readonly id: string, -    readonly desc: string = "", -    readonly triggerEvent: string = "", -    readonly steps: StepDefinition[] = [] -  ) {} -} - -// --- API del Builder --- - -export const Workflow = { -  /** Inicia la definición de un nuevo workflow */ -  make: (id: string): HatchetWorkflow => -    new HatchetWorkflow(id), - -  /** Añade una descripción */ -  description: (desc: string) => (wf: HatchetWorkflow) => -    new HatchetWorkflow(wf.id, desc, wf.triggerEvent, wf.steps), - -  /** Define el evento que lo dispara */ -  onEvent: (event: string) => (wf: HatchetWorkflow) => -    new HatchetWorkflow(wf.id, wf.desc, event, wf.steps), - -  /** * Añade un step. -   * ¡Magia de TS!: Combina las dependencias previas (R) con las del nuevo step (R2) -*/ -  addStep: (name: string, effect: Effect.Effect) => -(wf: HatchetWorkflow): HatchetWorkflow => -      new HatchetWorkflow(wf.id, wf.desc, wf.triggerEvent, [ -        ...wf.steps, -        { name, effect } as any -]) -}; - -2. El Registrador de Workflows (Worker Wrapper) - Ahora necesitamos una función que tome este HatchetWorkflow declarativo y haga todo el "trabajo sucio": obtener el Runtime, crear el Effectifier y registrar todo en el SDK nativo de Hatchet. - import { Effect } from "effect"; - // import { createHatchetEffectifier } from "./effectifier"; - // import { HatchetClient } from "./client"; - -export const registerWorkflow = ( -  workerName: string, -  workflow: HatchetWorkflow -): Effect.Effect => -  Effect.gen(function* () { -    // 1. Obtenemos el cliente inyectado -    const hatchet = yield* HatchetClient; -    const worker = hatchet.worker(workerName); - -    // 2. Extraemos el Runtime con TODAS las dependencias (R) acumuladas en el builder -    const runtime = yield* Effect.runtime(); - -    // 3. Creamos nuestra fábrica de adaptadores con ese runtime -    const effectify = createHatchetEffectifier(runtime); - -    // 4. Transformamos nuestro builder al formato que espera Hatchet -    yield* Effect.sync(() => { -      worker.registerWorkflow({ -        id: workflow.id, -        description: workflow.desc, -        on: { event: workflow.triggerEvent }, -        steps: workflow.steps.map(step => ({ -          name: step.name, -          // Convertimos cada Effect en una Promise de Hatchet aquí mismo -          run: effectify(step.effect) -        })) -      }); -    }); - -    yield* Effect.log(`Workflow '${workflow.id}' registrado en worker '${workerName}'`); - -    // 5. Iniciamos el worker -    yield* Effect.tryPromise(() => worker.start()).pipe( -      Effect.catchAllCause(cause => Effect.logError("Error iniciando worker", cause)) -    ); -  }); - -3. El Resultado Final: Tu App Nativa en Effect - Mira cómo queda la experiencia de desarrollo de quien usa tu librería. Es código puro, declarativo, funcional y altamente tipado. - import { Effect, pipe } from "effect"; - // Importaciones de tu app - // import { processPaymentStep, sendEmailStep, Database, EmailService } from "./mis-servicios"; - // Importaciones de tu librería - // import { Workflow, registerWorkflow, HatchetLive, HatchetLoggerLive } from "@effectify/hatchet"; - -// 1. Construimos el Workflow declarativamente -const paymentWorkflow = pipe( -  Workflow.make("payment-workflow"), -  Workflow.description("Procesa el pago y envía un recibo"), -  Workflow.onEvent("trigger:payment"), -  Workflow.addStep("process-payment", processPaymentStep), // Requiere Database -  Workflow.addStep("send-email", sendEmailStep)            // Requiere EmailService -); - -// En este punto, TypeScript sabe que paymentWorkflow es de tipo: -// HatchetWorkflow - -// 2. Creamos el programa principal -const mainProgram = registerWorkflow("pagos-worker", paymentWorkflow); - -// 3. Proveemos las dependencias y ejecutamos -const runnable = Effect.provide( -  mainProgram, -  // Proveemos todas las capas necesarias -  Effect.all([ -    HatchetLive, -    HatchetLoggerLive, -    DatabaseLive, -    EmailServiceLive -]) -); - -// Boom. -// Effect.runPromise(runnable); - -¿Por qué este patrón es brillante? - * Inferencia de Dependencias Perfecta: Si en el futuro añades un tercer paso Workflow.addStep("audit", auditStep) que requiere un AuditService, TypeScript inmediatamente te marcará un error en Effect.provide exigiéndote que proveas el AuditServiceLive. Cero bugs en producción por dependencias faltantes. - * Cero Boilerplate: El desarrollador de la app no tiene que lidiar con Promesas, contextos oscuros, ni adaptadores. Solo define sus pasos como Effect y los une con el Workflow.make. - * Escalabilidad: Puedes tener un archivo separado que exporte un array de HatchetWorkflow y luego iterarlos en tu programa principal para registrarlos todos de un golpe en el worker. -Con esto, tienes esencialmente una librería completa, moderna y lista para producción. Combina la robustez de Hatchet (concurrencia, retries, observabilidad) con la seguridad y composición de Effect (tipado estricto, inyección de dependencias, manejo de errores funcional). -¿Te gustaría repasar algún punto en particular de este PoC, o crees que tienes lo suficiente para empezar a codificar la librería real? diff --git a/docs/prds/PRD-effectify-hatchet.md b/docs/prds/PRD-effectify-hatchet.md deleted file mode 100644 index dbf8af27..00000000 --- a/docs/prds/PRD-effectify-hatchet.md +++ /dev/null @@ -1,1392 +0,0 @@ -# PRD: @effectify/hatchet - -**Product Requirements Document** - ---- - -## 1. Resumen Ejecutivo - -**@effectify/hatchet** es un package npm del **monorepositorio effectify** que proporciona una capa de integración nativa entre el ecosistema [Effect](https://effect.website/) (versión 4 beta) y [Hatchet](https://hatchet.run/), un sistema de workflows y background jobs distribuido. - -### Propósito en el Monorepositorio - -Este package sigue la filosofía del monorepositorio de crear **bindings nativos de Effect** para herramientas de terceros. Al igual que otros packages del monorepositorio (ej. `@effectify/react-router`, `@effectify/sql`), este módulo permite que aplicaciones nativas con Effect puedan integrar dependencias de terceros de manera типово-safe y funcional. - -### Propuesta de Valor - -| Antes (sin la librería) | Después (con @effectify/hatchet) | -| --------------------------------------------------- | ---------------------------------------------------------- | -| Steps escritos como funciones async (`Promise`) | Steps escritos como `Effect` puro | -| Manejo de errores con try/catch y thrown exceptions | Errores tipados en el canal `E` de Effect | -| Dependencias pasadas como parámetros | Inyección de dependencias via ServiceMap | -| Logging manual a Hatchet con `ctx.log()` | Uso nativo de `Effect.log()` con sincronización automática | -| Input no tipado (`any`) | Validación automática con `@effect/schema` | -| Configuración dispersa en múltiples lugares | Configuración centralizada via Effect Config | -| Tests contra servicios externos complejos | Tests contra Hatchet real en Docker Compose | - ---- - -## 2. Contexto Histórico: Los Ejemplos Originales (Effect v3) - -> **Nota importante**: Los siguientes ejemplos fueron extraídos de una conversación inicial con un LLM realizada hace unos meses. Estos códigos están escritos en **Effect v3** y contenían varios patrones que **NO son válidos en Effect v4**. Se incluyen aquí como referencia histórica del pensamiento inicial. - -### 2.1 Primera Versión del Effectifier (v3 - Obsoleto) - -```typescript -// ⚠️ CÓDIGO V3 - REFERENCIA HISTÓRICA SOLAMENTE -// NO USAR - contiene patrones incorrectos para v4 - -import { Context, Data, Effect, Layer } from "effect" -import { Hatchet } from "@hatchet-dev/typescript-sdk" - -// Este código usa Context.Tag que en v4 es ServiceMap.Service -export class HatchetClient extends Context.Tag("@effectify/hatchet/Client")< - HatchetClient, - Hatchet ->() {} - -// ❌ PROBLEMA: Runtime.runPromiseExit NO existe así en v4 -export const effectifyStep = ( - effectStep: (ctx: HatchetContext) => Effect.Effect, -) => { - return async (ctx: HatchetContext): Promise => { - return await Effect.runPromise(effectStep(ctx)) // ❌ Incorrecto en v4 - } -} -``` - -### 2.2 Problema del Runtime (v3 - Obsoleto) - -```typescript -// ⚠️ CÓDIGO V3 - REFERENCIA HISTÓRICA SOLAMENTE -// ❌ PROBLEMA: Runtime fue eliminado en v4 -export const createHatchetEffectifier = (runtime: Runtime.Runtime) => { - return (effectStep) => { - return async (ctx) => { - // ❌ Runtime.runPromiseExit NO existe en v4 - const exit = await Runtime.runPromiseExit(runtime)(effectStep(ctx)) - // ... - } - } -} -``` - ---- - -## 3. Goals (Objetivos) - -Los siguientes goals están basados en las features discutidas en el chat original. - -### Goal 1: Integración Nativa con Effect v4 - -**Descripción**: Cada step de un workflow Hatchet debe poder escribirse como un `Effect` puro, aprovechando todas las bondades del ecosistema Effect. - -**Detalles Técnicos (v4)**: - -- Usar `ServiceMap.Service` en lugar de `Context.Tag` -- El tipo `R` (dependencias) debe inferirse automáticamente -- Errores propagados correctamente para que Hatchet aplique retries -- Contexto de Hatchet disponible vía inyección de dependencias - -**Criterio de Éxito**: Un developer puede escribir un step que requiera una dependencia y se inyecte automáticamente. - ---- - -### Goal 2: Bidireccionalidad Effect ↔ Promise (Effectifier) - -**Descripción**: La librería debe actuar como puente bidireccional: - -1. **Effect → Promise**: Ejecutar un `Effect` dentro del runtime de Hatchet (que espera `Promise`) -2. **Promise → Effect**: Llamar a funciones del SDK de Hatchet desde Effect - -**Detalles Técnicos (v4)**: - -- Usar `Effect.runForkWith` junto con `Effect.services` para ejecutar Effects -- Convertir Failures a excepciones nativas para que Hatchet detecte errores -- Envolver llamadas SDK con `Effect.tryPromise` para tipar errores -- El Effectifier debe permitir que Hatchet ejecute steps definidos como Effects puros - -**Criterio de Éxito**: Un workflow que falla con `Effect.fail` debe aparecer como "failed" en el dashboard de Hatchet y triggear los retries configurados. - ---- - -### Goal 3: Inyección de Contexto de Hatchet (HatchetStepContext) - -**Descripción**: El contexto de Hatchet (input, output de steps anteriores, logger) debe estar disponible vía inyección de dependencias, NO como parámetros pasados a funciones. - -**Detalles Técnicos (v4)**: - -- Crear `HatchetStepContext` como ServiceMap.Service -- El step NO recibe ctx como parámetro, lo obtiene con `yield* HatchetStepContext` -- Permite acceder a: `workflowInput()`, `stepOutput()`, `log()` - -**Criterio de Éxito**: `yield* HatchetStepContext` devuelve el contexto con acceso a input (property `input`) y output de tasks padre (`parentOutput(taskRef)`). - ---- - -### Goal 4: Observabilidad Integrada (HatchetLogger) - -**Descripción**: Los logs generados con `Effect.log()` deben aparecer automáticamente en el dashboard de Hatchet sin necesidad de invocar `ctx.log()` manualmente. - -**Detalles Técnicos (v4)**: - -- Crear un Logger personalizado de Effect -- Detectar si existe `HatchetStepContext` en el Fiber actual -- Si existe, reenviar el log a `ctx.log()` de Hatchet -- Si no existe, comportarse como logger por defecto - -**Criterio de Éxito**: `yield* Effect.log("mensaje")` dentro de un step aparece en la UI de Hatchet. - ---- - -### Goal 5: Validación de Input con Schema - -**Descripción**: El input de un workflow debe validarse automáticamente contra un schema de `effect` (Schema) antes de ejecutar la lógica de negocio. - -**Detalles Técnicos (v4)**: - -- Usar `Schema.decodeUnknown` del paquete principal `effect` -- Proveer utilidad `getValidatedInput(schema)` -- Si la validación falla, el step falla con `Schema.ParseError` -- El tipo TypeScript debe inferirse del schema - -**Criterio de Éxito**: Input inválido falla con ParseError antes de ejecutar lógica. - ---- - -### Goal 6: API Declarativa Estilo Hatchet (Workflow Builder) - -**Descripción**: La API de definición de workflows debe ser muy similar a la de Hatchet, pero usando Effect. Los usuarios de Hatchet deben sentirse familiarizados. - -**Detalles Técnicos (v4)**: - -- Métodos similares a Hatchet: `workflow()`, `task()`, reemplazando el handler `fn` por un Effect -- Tasks definidos como Effects puros -- Inferencia automática de dependencias acumuladas -- Soporte para opciones de Hatchet: timeout, retry, parents (DAG), etc. - -**Criterio de Éxito**: Un workflow completo se registra en <10 líneas de código. - ---- - -### Goal 7: Configuración Centralizada (Effect Config) - -**Descripción**: Toda la configuración de Hatchet (token, host, namespace, etc.) debe estar centralizada usando Effect Config. - -**Detalles Técnicos (v4)**: - -- Usar `Config.Wrap<>` para definir configuración -- Crear servicio `HatchetConfig` via `ServiceMap.Service` -- El cliente se inicializa desde la configuración -- Permite sobrescribir en testing - -**Criterio de Éxito**: Un solo lugar para configurar Hatchet, usado por todos los servicios de la librería. - ---- - -### Goal 8: Testing Robusto con Docker Compose - -**Descripción**: Tests de integración contra Hatchet real en Docker, incluyendo PostgreSQL (necesario para Hatchet). - -**Detalles Técnicos**: - -- Docker Compose con Hatchet + PostgreSQL (no emulador) -- Tests de integración que ejecutan workflows reales -- Verificación de: registro, ejecución, retries, logs -- Helpers de testing para tests unitarios sin dependencias externas - -**Criterio de Éxito**: >90% coverage en tests unitarios, tests de integración passing contra Hatchet real. - ---- - -### Goal 9: Ejemplo en React Router Example - -**Descripción**: La app `react-router-example` del monorepo debe incluir un ejemplo funcional de la librería. - -**Detalles Técnicos**: - -- Worker de Hatchet corriendo como proceso separado -- Routes para dispara y monitorear workflows -- docker-compose con Hatchet + PostgreSQL + App + Worker - -**Criterio de Éxito**: La app ejemplo demuestra workflows funcionales. - ---- - -### Goal 10: Documentación del Package - -**Descripción**: El package debe incluir un README.md completo con setup, instalación y ejemplos. - -**Detalles Técnicos**: - -- Sección de instalación (npm/pnpm) -- Sección de configuración -- Ejemplos de uso básicos y avanzados -- API reference resumida - -**Criterio de Éxito**: Un developer puede usar la librería siguiendo solo el README. - ---- - -## 4. Arquitectura de Módulos - -``` -@effectify/hatchet/ -├── src/ -│ ├── index.ts # Exports públicos -│ │ -│ ├── core/ -│ │ ├── config.ts # HatchetConfig + Effect Config -│ │ ├── client.ts # HatchetClientService + HatchetClientLive -│ │ ├── error.ts # HatchetError (TaggedError) -│ │ └── context.ts # HatchetStepContext + getHatchetInput -│ │ -│ ├── effectifier/ -│ │ ├── execute.ts # effectifyTask + createEffectifierFromLayer -│ │ └── types.ts # Tipos internos -│ │ -│ ├── workflow/ -│ │ ├── workflow.ts # workflow() + EffectWorkflow class -│ │ ├── task.ts # task() function (replaces step()) -│ │ ├── register.ts # registerWorkflow() -│ │ └── types.ts # TaskOptions, WorkflowOptions -│ │ -│ ├── logging/ -│ │ ├── hatchet-logger.ts # HatchetLogger + withHatchetLogger -│ │ └── index.ts # Exports -│ │ -│ ├── schema/ -│ │ ├── get-validated-input.ts # getValidatedInput -│ │ └── index.ts # Exports -│ │ -│ └── testing/ -│ ├── mock-context.ts # createMockStepContext + runTestTask -│ └── index.ts # Exports -│ -├── tests/ -│ ├── unit/ -│ │ ├── client.test.ts -│ │ ├── effectifier.test.ts -│ │ ├── logger.test.ts -│ │ ├── schema.test.ts -│ │ └── workflow.test.ts -│ │ -│ └── integration/ -│ ├── docker-compose.yml # Hatchet + PostgreSQL -│ └── workflow.test.ts # Tests contra Hatchet real -│ -├── package.json -├── tsconfig.json -├── vitest.config.ts -└── README.md -``` - ---- - -## 5. API Propuesta - Effect v4 - -### 5.1 Módulo: Core - Configuración (v4 Pattern) - -```typescript -// src/core/config.ts -import { Config, Effect, Layer, Schema, ServiceMap } from "effect" - -// Esquema de configuración -const HatchetConfigSchema = Schema.Struct({ - token: Schema.String, - host: Schema.String.pipe(Schema.defaultTo("http://localhost:8080")), - namespace: Schema.optional(Schema.String), -}) - -type HatchetConfigType = Schema.Schema.Type - -// ✅ ServiceMap.Service en lugar de Context.Tag -export class HatchetConfig extends ServiceMap.Service< - HatchetConfig, - HatchetConfigType ->()("HatchetConfig") {} - -// Layer que provee la configuración -export const HatchetConfigLayer = ( - config: HatchetConfigType, -): Layer.Layer => Layer.succeed(HatchetConfig, config) - -// Layer desde Config.Wrap -export const HatchetConfigLayerFromEnv = ( - config: Config.Wrap, -): Layer.Layer => - Layer.effect(HatchetConfig)(Effect.map(Config.unwrap(config), (c) => c)) -``` - -### 5.2 Módulo: Core - Cliente (v4 Pattern) - -```typescript -// src/core/client.ts -import { Data, Effect, Layer, ServiceMap } from "effect" -import { HatchetClient } from "@hatchet-dev/typescript-sdk" - -// ✅ Errores usando Data.TaggedError -export class HatchetError extends Data.TaggedError( - "@effectify/hatchet/HatchetError", -)<{ - readonly message: string - readonly cause?: unknown -}> {} - -// ✅ ServiceMap.Service para el cliente -export class HatchetClientService extends ServiceMap.Service< - HatchetClientService, - HatchetClient ->()("HatchetClient") {} - -// Layer para inicializar el cliente -export const HatchetClientLive = Layer.effect(HatchetClientService)( - Effect.gen(function*() { - const config = yield* HatchetConfig - // ✅ SDK real: HatchetClient.init() - const hatchet = HatchetClient.init({ - token: config.token, - host_port: config.host, - }) - return hatchet - }), -) -``` - -### 5.3 Módulo: Core - Contexto del Step (v4 Pattern) - -```typescript -// src/core/context.ts -import { Effect, ServiceMap } from "effect" -import type { Context as HatchetContext } from "@hatchet-dev/typescript-sdk" - -export class HatchetStepContext extends ServiceMap.Service< - HatchetStepContext, - HatchetContext ->()("HatchetStepContext") {} - -// ✅ Utility to access input (SDK v1: input is a property, not a method) -// Usage: const input = yield* getHatchetInput() -export const getHatchetInput = () => Effect.map(HatchetStepContext, (ctx) => ctx.input as T) -``` - -### 5.4 Módulo: Effectifier - -```typescript -// src/effectifier/execute.ts -import { Cause, Effect, ManagedRuntime } from "effect" -import type { Context as HatchetContext } from "@hatchet-dev/typescript-sdk" -import { HatchetStepContext } from "../core/context" - -// ✅ Effectifier: ejecuta un Effect en el contexto de Hatchet -// Convierte Effect → Promise para que Hatchet ejecute el task -// Si el Effect falla, hace throw para que Hatchet aplique retries - -export const effectifyTask = ( - effect: Effect.Effect, - runtime: ManagedRuntime.ManagedRuntime, -) => { - return async (input: unknown, ctx: HatchetContext): Promise => { - // 1. Inyectamos el contexto de Hatchet como servicio - const effectWithContext = Effect.provideService( - effect, - HatchetStepContext, - ctx, - ) - - // 2. Ejecutamos con ManagedRuntime (no Effect.runtime() que no existe en v4) - const exit = await runtime.runPromiseExit(effectWithContext) - - // 3. Convertimos el resultado - if (exit._tag === "Success") { - return exit.value - } else { - // ✅ Convertir failure a excepción para que Hatchet haga retry - const error = Cause.squash(exit.cause) - throw error instanceof Error ? error : new Error(String(error)) - } - } -} - -// ✅ Fábrica: crea un Effectifier desde un Layer -// Usage: const effectify = createEffectifierFromLayer(MyAppLayer) -export const createEffectifierFromLayer = ( - layer: Layer.Layer, -) => { - const runtime = ManagedRuntime.make(layer) - return (effect: Effect.Effect) => effectifyTask(effect, runtime) -} -``` - -### 5.5 Módulo: Workflow (API Estilo Hatchet) - -```typescript -// src/workflow/types.ts -import type { RetryOpts, TaskConcurrency } from "@hatchet-dev/typescript-sdk" - -export interface TaskOptions { - readonly name: string - readonly timeout?: string - readonly retries?: number - readonly rateLimits?: Array<{ key: string; limit: number; duration: string }> - readonly concurrency?: TaskConcurrency[] - readonly parents?: string[] // DAG: parent task names -} - -export interface WorkflowOptions { - readonly name: string - readonly description?: string - readonly version?: string - readonly sticky?: boolean - readonly concurrency?: TaskConcurrency[] -} - -export interface TaskDefinition { - readonly options: TaskOptions - readonly effect: Effect.Effect -} -``` - -```typescript -// src/workflow/workflow.ts -import { Effect } from "effect" -import type { TaskDefinition, TaskOptions, WorkflowOptions } from "./types" - -export class EffectWorkflow { - readonly tasks: TaskDefinition[] = [] - - constructor( - readonly options: WorkflowOptions, - readonly dependencies: R = undefined as R, - ) {} - - // ✅ Adds a task (replaces step() — SDK uses .task()) - task( - options: TaskOptions, - effect: Effect.Effect, - ): EffectWorkflow { - this.tasks.push({ options, effect } as TaskDefinition) - return this as any - } -} - -export const workflow = (options: WorkflowOptions) => new EffectWorkflow(options) -``` - -```typescript -// src/workflow/register.ts -import { Effect, ManagedRuntime } from "effect" -import { HatchetClientService } from "../core/client" -import { HatchetStepContext } from "../core/context" -import { effectifyTask } from "../effectifier/execute" -import type { EffectWorkflow } from "./workflow" - -// ✅ registerWorkflow: registra un EffectWorkflow en Hatchet -// Reemplaza el boilerplate manual de crear tasks con effectifyStep -export const registerWorkflow = ( - workerName: string, - wf: EffectWorkflow, - layer: Layer.Layer, -): Effect.Effect => - Effect.gen(function*() { - const hatchet = yield* HatchetClientService - const runtime = ManagedRuntime.make(layer) - - // ✅ SDK real: hatchet.workflow({ name }) - const hatchetWorkflow = hatchet.workflow({ - name: wf.options.name, - ...(wf.options.description && { description: wf.options.description }), - ...(wf.options.version && { version: wf.options.version }), - }) - - // ✅ SDK real: workflow.task({ name, fn }) - // Convertimos cada Effect.Task a un task de Hatchet - wf.tasks.forEach((taskDef) => { - hatchetWorkflow.task({ - name: taskDef.options.name, - fn: effectifyTask(taskDef.effect, runtime), - ...(taskDef.options.retries && { retries: taskDef.options.retries }), - ...(taskDef.options.timeout && { - execution_timeout: taskDef.options.timeout, - }), - ...(taskDef.options.parents && { parents: taskDef.options.parents }), - }) - }) - - // ✅ SDK real: hatchet.worker(name, { workflows: [wf] }) - const worker = yield* Effect.tryPromise({ - try: () => hatchet.worker(workerName, { workflows: [hatchetWorkflow] }), - catch: (e) => new HatchetError({ message: "Failed to create worker", cause: e }), - }) - - yield* Effect.log( - `Workflow '${wf.options.name}' registered on worker '${workerName}'`, - ) - - // Iniciar el worker - yield* Effect.tryPromise({ - try: () => worker.start(), - catch: (e) => new HatchetError({ message: "Failed to start worker", cause: e }), - }) - }) -``` - -### 5.6 Módulo: Logging - -```typescript -// src/logging/hatchet-logger.ts -import { Effect, Logger, Option, ServiceMap } from "effect" - -export const HatchetLogger = Logger.make(({ logLevel, message, context }) => { - const msg = typeof message === "string" ? message : String(message) - - // ✅ Buscamos el contexto de Hatchet dentro del Fiber actual - const hatchetCtxOpt = ServiceMap.getOption(context, HatchetStepContext) - - if (Option.isSome(hatchetCtxOpt)) { - // Estamos dentro de un task de Hatchet — enviamos log a su UI - hatchetCtxOpt.value.log(`[${logLevel.label}] ${msg}`) - } - - // Mantenemos el log local en consola - console.log(`[${logLevel.label}] ${msg}`) -}) - -// ✅ Logger.replace NO existe en v4 — usar Effect.withLogger -export const withHatchetLogger = ( - effect: Effect.Effect, -): Effect.Effect => Effect.withLogger(effect, HatchetLogger) -``` - -### 5.7 Módulo: Schema - -```typescript -// src/schema/get-validated-input.ts -import { Effect, Schema } from "effect" -import { HatchetStepContext } from "../core/context" - -// ✅ Extrae y valida el input del workflow contra un schema -// ✅ Schema es parte del paquete principal 'effect', NO '@effect/schema' -export const getValidatedInput = ( - schema: Schema.Schema, -): Effect.Effect => - Effect.gen(function*() { - const ctx = yield* HatchetStepContext - // ✅ SDK v1: input es una property, no ctx.workflowInput() - const rawInput = ctx.input - const decode = Schema.decodeUnknown(schema) - return yield* decode(rawInput) - }) -``` - -### 5.8 Módulo: Testing - -```typescript -// src/testing/mock-context.ts -import { Effect, Exit, ServiceMap } from "effect" -import { HatchetStepContext } from "../core/context" - -export const createMockStepContext = (input?: unknown): any => ({ - input: input ?? {}, // ✅ SDK v1: input es property - parentOutput: async () => null, - log: async () => {}, - logger: { - info: async () => {}, - debug: async () => {}, - warn: async () => {}, - error: async () => {}, - }, - workflowRunId: () => "test-run-id", - workflowName: () => "test-workflow", - taskName: () => "test-task", - retryCount: () => 0, -}) - -export const runTestTask = ( - effect: Effect.Effect, - mockContext?: any, -): Effect.Effect, never, R> => { - const ctx = mockContext ?? createMockStepContext() - return effect.pipe( - Effect.provideService(HatchetStepContext, ctx), - Effect.exit, - ) as any -} -``` - ---- - -## 6. Ejemplo Completo de Uso - Effect v4 (API Real del SDK) - -### 6.1 Definición de Workflow - -```typescript -// workflows/user-onboarding.ts -import { Effect, Schema } from "effect" -import { getValidatedInput, HatchetStepContext, task, workflow } from "@effectify/hatchet" - -const UserInputSchema = Schema.Struct({ - userId: Schema.String, - email: Schema.String.pipe(Schema.email()), -}) - -const fetchUserTask = task( - { name: "fetch-user", timeout: "30s" }, - Effect.gen(function*() { - const input = yield* getValidatedInput(UserInputSchema) - const db = yield* Database - yield* Effect.log(`Fetching user ${input.userId}`) - return yield* db.findUser(input.userId) - }), -) - -const sendEmailTask = task( - { name: "send-email", retries: 3, parents: ["fetch-user"] }, - Effect.gen(function*() { - const ctx = yield* HatchetStepContext - // ✅ SDK v1: parentOutput(taskRef) replaces deprecated stepOutput - const user = yield* Effect.tryPromise({ - try: () => ctx.parentOutput<{ email: string }>(fetchUserTask), - catch: (e) => new HatchetError({ message: "Failed to get user", cause: e }), - }) - const emailService = yield* EmailService - yield* Effect.log(`Sending email to ${user.email}`) - return yield* emailService.send(user.email, "Welcome!") - }), -) - -export const userOnboardingWorkflow = workflow({ - name: "user-onboarding", - description: "Onboarding workflow", -}) - .task(fetchUserTask) - .task(sendEmailTask) -``` - -### 6.2 Registro del Worker - -```typescript -// worker/index.ts -import { Effect, Layer } from "effect" -import { - HatchetClientLive, - HatchetConfig, - HatchetConfigLayer, - registerWorkflow, - withHatchetLogger, -} from "@effectify/hatchet" -import { userOnboardingWorkflow } from "./workflows/user-onboarding" - -const mainProgram = Effect.gen(function*() { - const cfg = yield* HatchetConfig - yield* Effect.log(`Conectando a Hatchet en ${cfg.host}`) - yield* registerWorkflow("main-worker", userOnboardingWorkflow, DatabaseLive) - yield* Effect.log("Worker iniciado") -}) - -const runnable = withHatchetLogger( - Effect.provide( - mainProgram, - Layer.mergeAll( - HatchetConfigLayer({ - token: process.env.HATCHET_TOKEN!, - host: process.env.HATCHET_HOST ?? "http://localhost:8080", - }), - HatchetClientLive, - DatabaseLive, - EmailServiceLive, - ), - ), -) - -Effect.runPromise(runnable) -``` - ---- - -## 7. Docker Compose para Desarrollo - -### 7.1 docker-compose.yml (Development) - -```yaml -version: "3.8" - -services: - # PostgreSQL requerido por Hatchet - postgres: - image: postgres:16-alpine - ports: - - "5432:5432" - environment: - POSTGRES_USER: hatchet - POSTGRES_PASSWORD: hatchet - POSTGRES_DB: hatchet - volumes: - - hatchet_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U hatchet"] - interval: 5s - timeout: 3s - retries: 5 - - # Hatchet Engine - hatchet: - image: ghcr.io/hatchet-dev/hatchet:latest - ports: - - "8080:8080" - environment: - - HATCHET_SERVER_TOKEN=${HATCHET_TOKEN:-test-token} - - HATCHET_SERVER_URL=http://localhost:8080 - - DATABASE_URL=postgresql://hatchet:hatchet@postgres:5432/hatchet - - HATCHET_PG_MIN_IDLE_CONNS=1 - - HATCHET_PG_MAX_IDLE_CONNS=2 - depends_on: - postgres: - condition: service_healthy - -volumes: - hatchet_data: -``` - ---- - -## 8. React Router Example - -### 8.1 Estructura - -``` -apps/react-router-example/ -├── src/ -│ ├── routes/ -│ │ ├── _index.tsx # Dashboard de workflows -│ │ ├── api.workflows.trigger.tsx # Endpoint para dispara -│ │ └── api.workflows.status.tsx # Status del workflow -│ ├── services/ -│ │ └── hatchet.ts # Cliente de Hatchet -│ ├── worker/ -│ │ ├── index.ts # Entry point del worker -│ │ └── workflows/ -│ │ ├── hello.ts # Ejemplo simple -│ │ └── user-onboarding.ts # Ejemplo completo -│ └── lib/ -│ └── hatchet-setup.ts # Setup de layers -├── docker-compose.yml # Hatchet + App + Worker -├── Dockerfile.worker # Worker单独 -└── package.json -``` - -### 8.2 docker-compose.yml del Ejemplo - -```yaml -version: "3.8" - -services: - # PostgreSQL - postgres: - image: postgres:16-alpine - ports: - - "5432:5432" - environment: - POSTGRES_USER: hatchet - POSTGRES_PASSWORD: hatchet - POSTGRES_DB: hatchet - volumes: - - hatchet_data:/var/lib/postgresql/data - - # Hatchet Engine - hatchet: - image: ghcr.io/hatchet-dev/hatchet:latest - ports: - - "8080:8080" - environment: - - HATCHET_SERVER_TOKEN=${HATCHET_TOKEN:-test-token} - - DATABASE_URL=postgresql://hatchet:hatchet@postgres:5432/hatchet - depends_on: - postgres: - condition: service_healthy - - # React Router App - app: - build: . - ports: - - "3000:3000" - environment: - - DATABASE_URL=file:./data.db - - HATCHET_TOKEN=${HATCHET_TOKEN:-test-token} - - HATCHET_HOST=http://hatchet:8080 - depends_on: - - hatchet - volumes: - - ./data:/app/data - - # Worker de Hatchet - worker: - build: - context: . - dockerfile: Dockerfile.worker - environment: - - HATCHET_TOKEN=${HATCHET_TOKEN:-test-token} - - HATCHET_HOST=http://hatchet:8080 - - DATABASE_URL=postgresql://hatchet:hatchet@postgres:5432/hatchet - depends_on: - - hatchet - -volumes: - hatchet_data: -``` - -### 8.3 Ejemplo Simple: Hello World - -```typescript -// src/worker/workflows/hello.ts -import { Effect } from "effect" -import { task, workflow } from "@effectify/hatchet" - -// Task simple que retorna un mensaje -const helloTask = task( - { name: "hello" }, - Effect.gen(function*() { - yield* Effect.log("Ejecutando hello task") - return { - message: "Hello from Effect v4 + Hatchet!", - timestamp: new Date().toISOString(), - } - }), -) - -export const helloWorkflow = workflow({ - name: "hello-world", - description: "Ejemplo simple de workflow con Effect v4", -}).task(helloTask) -``` - -### 8.4 Ejemplo Completo: User Onboarding - -```typescript -// src/worker/workflows/user-onboarding.ts -import { Effect, Schema } from "effect" -import { getValidatedInput, HatchetError, HatchetStepContext, task, workflow } from "@effectify/hatchet" - -// Schema de validación -const UserInputSchema = Schema.Struct({ - userId: Schema.String, - email: Schema.String.pipe(Schema.email()), - name: Schema.String, -}) - -// Task 1: Validar y crear usuario -const createUserTask = task( - { name: "create-user", timeout: "30s" }, - Effect.gen(function*() { - const input = yield* getValidatedInput(UserInputSchema) - const db = yield* Database - - yield* Effect.log(`Creando usuario: ${input.name} (${input.email})`) - - const user = yield* db.createUser({ - id: input.userId, - name: input.name, - email: input.email, - }) - - return user - }), -) - -// Task 2: Enviar email de bienvenida (depends on createUserTask) -const sendWelcomeEmailTask = task( - { name: "send-welcome-email", retries: 3, parents: ["create-user"] }, - Effect.gen(function*() { - const ctx = yield* HatchetStepContext - // ✅ SDK v1: parentOutput(taskRef) replaces deprecated stepOutput - const user = yield* Effect.tryPromise({ - try: () => ctx.parentOutput<{ email: string; name: string }>(createUserTask), - catch: (e) => new HatchetError({ message: "Failed to get parent output", cause: e }), - }) - const emailService = yield* EmailService - - yield* Effect.log(`Enviando email a ${user.email}`) - - yield* emailService.send(user.email, "Bienvenido a la plataforma!") - - return { emailSent: true } - }), -) - -// Task 3: Logging final (depends on sendWelcomeEmailTask) -const notifyAdminTask = task( - { name: "notify-admin", parents: ["create-user"] }, - Effect.gen(function*() { - const ctx = yield* HatchetStepContext - const user = yield* Effect.tryPromise({ - try: () => ctx.parentOutput<{ email: string; name: string }>(createUserTask), - catch: (e) => new HatchetError({ message: "Failed to get parent output", cause: e }), - }) - - yield* Effect.log(`Nuevo usuario registrado: ${user.name} <${user.email}>`) - - return { notified: true } - }), -) - -// Workflow completo (DAG con tasks) -export const userOnboardingWorkflow = workflow({ - name: "user-onboarding", - description: "Workflow de onboarding de nuevos usuarios", -}) - .task(createUserTask) - .task(sendWelcomeEmailTask) - .task(notifyAdminTask) -``` - -### 8.5 Routes del Ejemplo - -```typescript -// src/routes/api.workflows.trigger.tsx -import { type ActionFunctionArgs, json } from "react-router" -import { Effect } from "effect" -import { AppLayers, triggerWorkflow } from "~/lib/hatchet-setup" - -export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData() - const userId = formData.get("userId") as string - const email = formData.get("email") as string - const name = formData.get("name") as string - - const program = Effect.gen(function*() { - yield* Effect.log(`Triggering onboarding para ${email}`) - const result = yield* triggerWorkflow("user-onboarding", { - userId, - email, - name, - }) - yield* Effect.log(`Workflow iniciado: ${result.workflowRunId}`) - return { workflowRunId: result.workflowRunId } - }) - - const result = await Effect.runPromise(Effect.provide(program, AppLayers)) - return json({ success: true, workflowRunId: result.workflowRunId }) -} - -// src/routes/api.workflows.status.tsx -import { json, type LoaderFunctionArgs } from "react-router" -import { Effect } from "effect" -import { AppLayers, getWorkflowStatus } from "~/lib/hatchet-setup" - -export async function loader({ request }: LoaderFunctionArgs) { - const url = new URL(request.url) - const workflowRunId = url.searchParams.get("workflowRunId") - - if (!workflowRunId) { - return json({ error: "workflowRunId requerido" }, { status: 400 }) - } - - const program = getWorkflowStatus(workflowRunId) - const result = await Effect.runPromise(Effect.provide(program, AppLayers)) - - return json(result) -} -``` - ---- - -## 9. README.md del Package - -El package debe incluir un `README.md` completo: - -````markdown -# @effectify/hatchet - -> Integración nativa entre Effect v4 y Hatchet - -## Instalación - -```bash -npm install @effectify/hatchet -# o -pnpm add @effectify/hatchet -``` - -## Requisitos - -- Effect v4 (`effect` package) -- `@hatchet-dev/typescript-sdk` v1+ -- Hatchet Engine corriendo (ver docker-compose) - -## Configuración rápida - -### 1. Docker Compose - -```yaml -# docker-compose.yml -version: "3.8" -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: hatchet - POSTGRES_PASSWORD: hatchet - POSTGRES_DB: hatchet - - hatchet: - image: ghcr.io/hatchet-dev/hatchet:latest - environment: - - HATCHET_SERVER_TOKEN=tu-token - - DATABASE_URL=postgresql://hatchet:hatchet@postgres:5432/hatchet - depends_on: - - postgres -``` - -### 2. Definir un Workflow - -```typescript -import { Effect, Schema } from "effect" -import { getValidatedInput, HatchetStepContext, task, workflow } from "@effectify/hatchet" - -// Schema de validación del input -const InputSchema = Schema.Struct({ - name: Schema.String, - email: Schema.String.pipe(Schema.email()), -}) - -// Task como Effect puro -const greetTask = task( - { name: "greet", timeout: "30s" }, - Effect.gen(function*() { - const input = yield* getValidatedInput(InputSchema) - yield* Effect.log(`Hola, ${input.name}!`) - return { greeting: `Hola, ${input.name}!` } - }), -) - -// Definir workflow -export const greetWorkflow = workflow({ - name: "greet-user", - description: "Saluda a un usuario", -}).task(greetTask) -``` - -### 3. Iniciar el Worker - -```typescript -import { Effect, Layer } from "effect" -import { HatchetClientLive, HatchetConfigLayer, registerWorkflow, withHatchetLogger } from "@effectify/hatchet" -import { greetWorkflow } from "./workflows/greet" - -const main = Effect.gen(function*() { - yield* registerWorkflow("my-worker", greetWorkflow, Layer.empty) - yield* Effect.log("Worker iniciado") -}) - -Effect.runPromise( - withHatchetLogger( - Effect.provide( - main, - Layer.mergeAll( - HatchetConfigLayer({ - token: process.env.HATCHET_TOKEN!, - host: process.env.HATCHET_HOST ?? "http://localhost:8080", - }), - HatchetClientLive, - ), - ), - ), -) -``` - -### 4. Disparar desde tu App - -```typescript -import { Effect } from "effect" -import { HatchetClientService, HatchetError } from "@effectify/hatchet" - -const triggerGreeting = (name: string, email: string) => - Effect.gen(function*() { - const hatchet = yield* HatchetClientService - const result = yield* Effect.tryPromise({ - try: () => hatchet.admin.runWorkflow("greet-user", { name, email }), - catch: (e) => new HatchetError({ message: "Error al ejecutar", cause: e }), - }) - return result - }) -``` - -## API - -### `workflow(options)` - -Define un workflow estilo Hatchet. - -```typescript -const myWorkflow = workflow({ - name: "my-workflow", - description: "Descripción", -}) -``` - -### `task(options, effect)` - -Define un task como Effect puro (replaces step). - -```typescript -task( - { - name: "my-task", - timeout: "30s", // timeout opcional - retries: 3, // retries opcional - parents: ["other-task"], // DAG: dependencias opcionales - }, - Effect.gen(function*() { - // Tu lógica como Effect - return { result: "ok" } - }), -) -``` - -### `getValidatedInput(schema)` - -Extrae y valida el input del workflow (SDK v1: usa `ctx.input`). - -```typescript -const myTask = task( - { name: "process" }, - Effect.gen(function*() { - const input = yield* getValidatedInput(MySchema) - // input está tipado y validado - return input - }), -) -``` - -## Errores - -Todos los errores usan `Data.TaggedError`: - -```typescript -import { HatchetError } from "@effectify/hatchet" - -Effect.gen(function*() { - // ... -}).pipe( - Effect.catchTag("HatchetError", (e) => Effect.log(`Error: ${e.message}`)), -) -``` -```` - -## Testing - -```typescript -import { createMockStepContext, runTestStep } from "@effectify/hatchet/testing" - -it("should process step", async () => { - const mockCtx = createMockStepContext({ name: "Test" }) - const result = await Effect.runPromiseExit( - runTestStep(myStep, Layer.empty, mockCtx), - ) - expect(Exit.isSuccess(result)).toBe(true) -}) -``` - -## Licencia - -MIT - -```` ---- - -## 10. Estrategia de Testing - -### 10.1 Tests Unitarios - -- Sin dependencias externas -- Coverage >90% en módulos core -- Uso de `createMockStepContext` y `runTestTask` - -### 10.2 Tests de Integración - -**Docker Compose con PostgreSQL**: - -```yaml -# tests/integration/docker-compose.yml -version: '3.8' - -services: - postgres-test: - image: postgres:16-alpine - environment: - POSTGRES_USER: hatchet - POSTGRES_PASSWORD: hatchet - POSTGRES_DB: hatchet - tmpfs: - - /var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U hatchet"] - - hatchet-test: - image: ghcr.io/hatchet-dev/hatchet:latest - environment: - - HATCHET_SERVER_TOKEN=test-token - - DATABASE_URL=postgresql://hatchet:hatchet@postgres-test:5432/hatchet - depends_on: - postgres-test: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 5s - timeout: 3s - retries: 15 -```` - -**Tests de integración**: - -```typescript -// tests/integration/workflow.test.ts -describe("Workflow Integration", () => { - beforeAll(async () => { - // Esperar a que Hatchet esté listo - await waitForHatchet("http://localhost:8080/health") - }) - - it("should register and execute workflow", async () => { - // Arrange - const testTask = task({ name: "test" }, Effect.succeed({ ok: true })) - const testWorkflow = workflow({ - name: "integration-test", - }).task(testTask) - - // Act - const program = Effect.gen(function*() { - yield* registerWorkflow("test-worker", testWorkflow, Layer.empty) - yield* Effect.sleep("1s") // Esperar registro - - const hatchet = yield* HatchetClientService - return yield* Effect.tryPromise(() => hatchet.admin.runWorkflow("integration-test", {})) - }) - - const result = await Effect.runPromise(Effect.provide(program, TestLayers)) - - // Assert - expect(result.workflowRunId).toBeDefined() - }) -}) -``` - ---- - -## 11. Patrones Obligatorios de Effect v4 - -### ✅ Patrones OBLIGATORIOS - -1. **NUNCA usar try-catch en Effect.gen** -2. **NUNCA usar type assertions (`as any`)** -3. **SIEMPRE usar `return yield*` para Effects terminal** -4. **Usar `ServiceMap.Service` en lugar de `Context.Tag`** -5. **Usar `ManagedRuntime.make(layer)` en lugar de `Effect.runtime()` (NO existe)** -6. **Usar `Schema` del paquete `effect`, NO de `@effect/schema` (paquete separado NO existe)** -7. **Usar `Effect.withLogger(effect, logger)` en lugar de `Logger.replace` (NO existe)** -8. **Usar `ServiceMap.getOption()` en lugar de `Context.getOption()` (módulo Context NO existe)** - -### ✅ APIs Verificadas (Existentes) - -- `ServiceMap.Service` — reemplaza `Context.Tag` -- `Effect.runForkWith(services)` — ejecutar Effect con services -- `Effect.provideService(key, value)` — inyectar un service -- `Config.Wrap` / `Config.unwrap(wrapped)` — configuración type-safe -- `Layer.succeed(key)(value)` — Layer estático -- `Layer.effect(key)(effect)` — Layer desde Effect -- `Layer.mergeAll(layers...)` — merge de layers -- `Data.TaggedError(tag)` — errores estructurados -- `Logger.make(fn)` — crear logger custom -- `Effect.runPromiseExit` / `Effect.runFork` — ejecución - ---- - -## 12. Roadmap - -### Milestone 1: Core + Config (Semana 1-2) - -- [ ] Setup del proyecto -- [ ] `HatchetConfig` con ServiceMap -- [ ] `HatchetClient` -- [ ] Tests unitarios - -### Milestone 2: Effectifier + Context (Semana 3) - -- [ ] `HatchetStepContext` -- [ ] `effectifyTask` + `createEffectifierFromLayer` -- [ ] Manejo de errores correcto (Failure → throw para Hatchet retries) - -### Milestone 3: Workflow API (Semana 4) - -- [ ] `workflow()` y `task()` (SDK usa `.task()`, no `.step()`) -- [ ] Inferencia de dependencias -- [ ] `registerWorkflow` - -### Milestone 4: Logging + Schema (Semana 5) - -- [ ] Logger personalizado -- [ ] Validación de input - -### Milestone 5: Docker + Testing (Semana 6) - -- [ ] Docker Compose con PostgreSQL -- [ ] Tests de integración - -### Milestone 6: React Router Example + README + Release (Semana 7-8) - -- [ ] Ejemplo completo en react-router-example -- [ ] README.md del package -- [ ] Release v0.1.0 - ---- - -## 13. Dependencias - -```json -{ - "dependencies": { - "@hatchet-dev/typescript-sdk": "^1.19.0" - }, - "peerDependencies": { - "effect": "catalog:" - }, - "devDependencies": { - "@effect/vitest": "catalog:", - "@types/node": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - } -} -``` - -**Nota**: `@effect/schema` NO es necesario — Schema está incluido en el paquete principal `effect`. - ---- - -## 14. Glosario - -| Término | Definición | -| -------------------- | ------------------------------------------------ | -| **Effect** | Framework de programación funcional v4 | -| **ServiceMap** | Sistema de inyección de dependencias en v4 | -| **Hatchet** | Sistema de workflows y background jobs | -| **Effectifier** | Adaptador que convierte Effect a Promise | -| **ManagedRuntime** | Ejecución de Effects con dependencias (v4) | -| **Layer** | Composición de dependencias en Effect | -| **Data.TaggedError** | Errores estructurados con discriminación | -| **Task** | Unidad de trabajo en Hatchet SDK v1 (era "step") | - ---- - -## 15. Referencias - -- **Effect v4 Patterns**: `.effect-reference/.patterns/` -- **Effect v4 Migration**: `.effect-reference/migration/` -- **Skill**: `.agent/skills/effect-pattern-discovery/SKILL.md` - ---- - -_Documento creado: Marzo 2026_ -_Versión: 1.1.0 (APIs verificadas)_ -_Estado: Draft - APIs verificadas contra Effect v4 + Hatchet SDK v1.19.0_ -_Cambios v1.1.0_: API verification — step→task, Schema de effect, ManagedRuntime, ctx.input, Effect.withLogger diff --git a/docs/proposals/hatchet-integration.md b/docs/proposals/hatchet-integration.md deleted file mode 100644 index 8a6cb10b..00000000 --- a/docs/proposals/hatchet-integration.md +++ /dev/null @@ -1,82 +0,0 @@ -# Proposal: @effectify/hatchet Integration - -## Intent - -Create `@effectify/hatchet` — a native Effect v4 integration with Hatchet SDK v1.19.0 for defining workflows as pure Effects. This enables users to write Hatchet tasks using `Effect` instead of async functions, leveraging typed errors, dependency injection via ServiceMap, and automatic logging sync. - -## Scope - -### In Scope - -- `@effectify/hatchet` package with core, effectifier, workflow, logging, schema, and testing modules -- Effectifier bridge converting Effect → Promise using ManagedRuntime -- Workflow builder API: `workflow({ name }).task(task1).task(task2)` pattern -- HatchetStepContext service for injecting Hatchet context into Effects -- Custom Effect logger that syncs to Hatchet UI -- Schema validation utilities for workflow input -- Unit tests with mock context (>90% coverage) -- Integration tests with Docker Compose (Hatchet + PostgreSQL) -- README.md with usage examples -- Release configuration in nx.json - -### Out of Scope - -- React Router example app integration (deferred to future change) -- Support for older Hatchet SDK versions (v0.x) - -## Approach - -1. **Setup Package Structure** — Create `packages/hatchet/` following `@effectify/prisma` patterns (project.json, package.json, tsconfig.json, vitest.config.ts) - -2. **Implement Core Modules** — Build HatchetConfig, HatchetClientService, HatchetStepContext, and HatchetError using ServiceMap.Service (NOT Context.Tag) - -3. **Build Effectifier** — Create `effectifyTask` using ManagedRuntime.make(layer) to convert Effect → Promise with proper error propagation for Hatchet retries - -4. **Create Workflow API** — Implement `workflow()` and `task()` functions matching Hatchet SDK v1 patterns (uses `.task()` not `.step()`) - -5. **Add Logging & Schema** — Implement HatchetLogger using Effect.withLogger and getValidatedInput using Schema.decodeUnknown - -6. **Write Tests** — Create unit tests with mock context and integration tests against Docker Compose - -## Dependencies - -- `@hatchet-dev/typescript-sdk: ^1.19.0` — Hatchet SDK (NOT yet installed) -- `effect: catalog:` — Effect v4 (already in monorepo) -- Docker Compose with Hatchet + PostgreSQL for integration tests - -## Risks - -| Risk | Likelihood | Mitigation | -| -------------------------------------------- | ---------- | --------------------------------------------------------------- | -| Hatchet SDK API differs from PRD assumptions | Medium | Use verified APIs: `.task()`, `ctx.input`, `ctx.parentOutput()` | -| Effect v4 APIs break during beta | Low | Use only verified APIs from `.effect-reference/` | -| ManagedRuntime leak in effectifier | Medium | Ensure runtime disposal in worker lifecycle | - -## Success Criteria - -- [ ] Package builds without errors using `nx build @effectify/hatchet` -- [ ] Unit tests pass (>90% coverage) via `nx test @effectify/hatchet` -- [ ] Integration tests pass against Docker Compose Hatchet -- [ ] README.md provides complete usage documentation -- [ ] Workflow fails correctly trigger Hatchet retries -- [ ] Effect.log() output appears in Hatchet dashboard - -## Affected Files - -| Area | Impact | Description | -| ---------------------------------------------- | -------- | --------------------------------------------- | -| `packages/hatchet/project.json` | New | Nx project configuration | -| `packages/hatchet/package.json` | New | Package manifest with Hatchet SDK dependency | -| `packages/hatchet/tsconfig.json` | New | TypeScript configuration | -| `packages/hatchet/vitest.config.ts` | New | Test configuration | -| `packages/hatchet/src/index.ts` | New | Public exports | -| `packages/hatchet/src/core/*.ts` | New | Config, client, context, error modules | -| `packages/hatchet/src/effectifier/*.ts` | New | Effect → Promise bridge | -| `packages/hatchet/src/workflow/*.ts` | New | Workflow builder API | -| `packages/hatchet/src/logging/*.ts` | New | Hatchet logger | -| `packages/hatchet/src/schema/*.ts` | New | Input validation | -| `packages/hatchet/src/testing/*.ts` | New | Test utilities | -| `packages/hatchet/tests/unit/*.test.ts` | New | Unit tests | -| `packages/hatchet/tests/integration/*.test.ts` | New | Integration tests | -| `packages/hatchet/README.md` | New | Package documentation | -| `nx.json` | Modified | Add release configuration for hatchet package | diff --git a/docs/specs/hatchet-spec.md b/docs/specs/hatchet-spec.md deleted file mode 100644 index d100a7ce..00000000 --- a/docs/specs/hatchet-spec.md +++ /dev/null @@ -1,719 +0,0 @@ -# @effectify/hatchet Specification - -This specification defines the requirements and scenarios for the `@effectify/hatchet` package, which provides a native Effect v4 integration with Hatchet SDK v1.21.0. - ---- - -## Table of Contents - -1. [Core Module Specs](#core-module-specs) -2. [Effectifier Module Specs](#effectifier-module-specs) -3. [Workflow Module Specs](#workflow-module-specs) -4. [Logging Module Specs](#logging-module-specs) -5. [Schema Module Specs](#schema-module-specs) -6. [Testing Module Specs](#testing-module-specs) -7. [Monorepo Setup Specs](#monorepo-setup-specs) -8. [Integration Specs](#integration-specs) - ---- - -## Core Module Specs - -### HatchetConfig Spec - -The HatchetConfig module provides centralized configuration management for the Hatchet integration using Effect's Config system. - -#### Requirements - -- [REQ-CORE-01] HatchetConfig MUST be defined as a ServiceMap.Service with the configuration schema type as its payload -- [REQ-CORE-02] HatchetConfig MUST support token, host, and namespace properties -- [REQ-CORE-03] HatchetConfig MUST provide a Layer for static configuration via HatchetConfigLayer -- [REQ-CORE-04] HatchetConfig MUST support loading from environment variables via Config.Wrap -- [REQ-CORE-05] Host MUST default to "http://localhost:8080" if not provided -- [REQ-CORE-06] Namespace MUST be optional - -#### Scenarios - -##### Scenario: Static Configuration Layer Creation - -Given a configuration object with token and host -When HatchetConfigLayer is called with that configuration -Then it returns a Layer that provides the configuration as a service - -##### Scenario: Configuration from Environment - -Given environment variables HATCHET_TOKEN and HATCHET_HOST -When HatchetConfigLayerFromEnv is called with wrapped config -Then it returns a Layer that reads from environment and provides the configuration - -##### Scenario: Default Host Value - -Given a configuration object with only a token -When HatchetConfigLayer processes that configuration -Then the host defaults to "http://localhost:8080" - -##### Scenario: Optional Namespace - -Given a configuration object without namespace -When HatchetConfigLayer processes that configuration -Then namespace is undefined in the provided service - ---- - -### HatchetClientService Spec - -The HatchetClientService module provides the Hatchet SDK client as an injectable Effect service. - -#### Requirements - -- [REQ-CLIENT-01] HatchetClientService MUST be defined as a ServiceMap.Service wrapping HatchetClient from @hatchet-dev/typescript-sdk -- [REQ-CLIENT-02] HatchetClientService MUST be initialized using HatchetClient.init() with token and host_port -- [REQ-CLIENT-03] HatchetClientLive MUST be a Layer that initializes the client from HatchetConfig -- [REQ-CLIENT-04] Initialization MUST handle Config errors and convert to HatchetError -- [REQ-CLIENT-05] The layer MUST depend on HatchetConfig to obtain connection parameters - -#### Scenarios - -##### Scenario: Client Initialization with Valid Config - -Given a HatchetConfig with valid token and host -When HatchetClientLive layer is built -Then it initializes HatchetClient with token and host_port - -##### Scenario: Client Initialization Fails - -Given a HatchetConfig with invalid token -When HatchetClientLive layer is executed -Then it fails with HatchetError containing the cause - -##### Scenario: Client Depends on Config Service - -Given HatchetClientLive is used without HatchetConfig -When the layer is built -Then it fails with a missing dependency error - ---- - -### HatchetError Spec - -The HatchetError module provides structured error handling using Data.TaggedError. - -#### Requirements - -- [REQ-ERROR-01] HatchetError MUST be defined using Data.TaggedError -- [REQ-ERROR-02] HatchetError MUST have a message property describing the error -- [REQ-ERROR-03] HatchetError MUST have an optional cause property for underlying errors -- [REQ-ERROR-04] HatchetError MUST be catchable using Effect.catchTag - -#### Scenarios - -##### Scenario: Creating HatchetError - -Given an error message and optional cause -When HatchetError is constructed -Then it creates a TaggedError with those properties - -##### Scenario: Catching HatchetError in Effect - -Given an Effect that fails with HatchetError -When the Effect is caught using Effect.catchTag("HatchetError") -Then the error handler receives the HatchetError instance - ---- - -### HatchetStepContext Spec - -The HatchetStepContext module provides the Hatchet step context as an injectable Effect service. - -#### Requirements - -- [REQ-CONTEXT-01] HatchetStepContext MUST be defined as a ServiceMap.Service -- [REQ-CONTEXT-02] HatchetStepContext MUST wrap the Hatchet SDK Context type -- [REQ-CONTEXT-03] getHatchetInput MUST extract and type the input property from the context -- [REQ-CONTEXT-04] The context MUST provide access to input, parentOutput, log, and logger properties -- [REQ-CONTEXT-05] Input access MUST work with the SDK v1.21.0 ctx.input property (not a method) - -#### Scenarios - -##### Scenario: Accessing Step Input - -Given a HatchetStepContext with input { userId: "123" } -When getHatchetInput is called and yielded -Then it returns the input typed as the generic type parameter - -##### Scenario: Accessing Parent Output - -Given a task with a parent task -When HatchetStepContext.parentOutput is called with a task reference -Then it returns the output of the parent task - -##### Scenario: Using Logger in Context - -Given a HatchetStepContext -When ctx.logger.info is called -Then it logs to the Hatchet dashboard - ---- - -## Effectifier Module Specs - -### effectifyTask Spec - -The effectifier module bridges Effect execution with Hatchet's Promise-based task system. - -#### Requirements - -- [REQ-EFFECT-01] effectifyTask MUST convert an Effect to a function compatible with Hatchet's task API -- [REQ-EFFECT-02] effectifyTask MUST accept an Effect with HatchetStepContext in its dependencies -- [REQ-EFFECT-03] effectifyTask MUST inject the Hatchet context as a service before execution -- [REQ-EFFECT-04] Success results MUST be returned as-is -- [REQ-EFFECT-05] Failure causes MUST be thrown as Errors for Hatchet retry detection -- [REQ-EFFECT-06] The function signature MUST match (input: unknown, ctx: HatchetContext) => Promise - -#### Scenarios - -##### Scenario: Successful Effect Execution - -Given an Effect that succeeds with a value -When effectifyTask executes the Effect -Then it returns the success value as a Promise - -##### Scenario: Failed Effect Throws Error - -Given an Effect that fails with an error -When effectifyTask executes the Effect -Then it throws an Error for Hatchet to detect and potentially retry - -##### Scenario: Context Injection - -Given an Effect that requires HatchetStepContext -When effectifyTask runs the Effect -Then HatchetStepContext is provided with the Hatchet context - -##### Scenario: ManagedRuntime Execution - -Given a ManagedRuntime with dependencies -When effectifyTask runs with that runtime -Then the Effect is executed using runtime.runPromiseExit - ---- - -### createEffectifierFromLayer Spec - -The factory function creates an effectifier with pre-configured dependencies. - -#### Requirements - -- [REQ-FACTORY-01] createEffectifierFromLayer MUST accept a Layer defining dependencies -- [REQ-FACTORY-02] createEffectifierFromLayer MUST create a ManagedRuntime from the layer -- [REQ-FACTORY-03] The returned function MUST accept an Effect with those dependencies -- [REQ-FACTORY-04] The runtime MUST be disposed when the worker shuts down - -#### Scenarios - -##### Scenario: Creating Effectifier with Dependencies - -Given a Layer with Database and EmailService -When createEffectifierFromLayer is called with that layer -Then it returns a function that can execute Effects requiring those services - -##### Scenario: Effectifier Inherits Dependencies - -Given an Effect that requires Database service -When the effectified function is called -Then it uses the dependencies from the layer - ---- - -## Workflow Module Specs - -### workflow() Spec - -The workflow function creates a declarative workflow builder matching Hatchet's API style. - -#### Requirements - -- [REQ-WF-01] workflow() MUST accept WorkflowOptions with name, description, version, sticky, and concurrency -- [REQ-WF-02] workflow() MUST return an EffectWorkflow instance -- [REQ-WF-03] The workflow name MUST be required -- [REQ-WF-04] Description and version MUST be optional - -#### Scenarios - -##### Scenario: Creating a Basic Workflow - -Given workflow options with a name -When workflow() is called -Then it returns an EffectWorkflow with empty tasks - -##### Scenario: Workflow with All Options - -Given workflow options with name, description, version, sticky, and concurrency -When workflow() is called -Then all options are stored in the EffectWorkflow - ---- - -### task() Spec - -The task function defines a single task within a workflow. - -#### Requirements - -- [REQ-TASK-01] task() MUST accept TaskOptions and an Effect -- [REQ-TASK-02] TaskOptions MUST include name (required), timeout, retries, rateLimits, concurrency, and parents -- [REQ-TASK-03] task() MUST return a TaskDefinition that can be added to a workflow -- [REQ-TASK-04] Parents MUST define DAG dependencies between tasks - -#### Scenarios - -##### Scenario: Creating a Basic Task - -Given task options with a name and an Effect -When task() is called -Then it returns a TaskDefinition with those options - -##### Scenario: Task with Retry Configuration - -Given task options with retries: 3 -When task() is called -Then the retry configuration is stored - -##### Scenario: Task with Parent Dependencies - -Given task options with parents: ["fetch-user", "validate-input"] -When task() is called -Then the parents are stored for DAG execution order - ---- - -### EffectWorkflow.task() Spec - -The task method adds a task to the workflow builder chain. - -#### Requirements - -- [REQ-WFTASK-01] EffectWorkflow.task() MUST accept a TaskDefinition -- [REQ-WFTASK-01] EffectWorkflow.task() MUST return a new EffectWorkflow with updated dependencies -- [REQ-WFTASK-02] Multiple calls to task() MUST accumulate tasks in the workflow -- [REQ-WFTASK-03] Dependencies from all tasks MUST be merged - -#### Scenarios - -##### Scenario: Adding Single Task to Workflow - -Given an EffectWorkflow with no tasks -When .task() is called with a TaskDefinition -Then the workflow contains one task - -##### Scenario: Chaining Multiple Tasks - -Given an EffectWorkflow -When .task() is called multiple times -Then all tasks are accumulated in the workflow - -##### Scenario: Dependency Inference Across Tasks - -Given tasks with different dependency requirements -When they are added to the workflow -Then the workflow dependencies are the union of all task dependencies - ---- - -### registerWorkflow() Spec - -The registerWorkflow function registers an EffectWorkflow with Hatchet. - -#### Requirements - -- [REQ-REG-01] registerWorkflow MUST accept worker name, EffectWorkflow, and a Layer -- [REQ-REG-02] registerWorkflow MUST return an Effect that registers the workflow -- [REQ-REG-03] The function MUST use hatchet.workflow() to create the workflow -- [REQ-REG-04] The function MUST use workflow.task() for each task (not step()) -- [REQ-REG-05] The function MUST create a worker with hatchet.worker() -- [REQ-REG-06] The function MUST start the worker -- [REQ-REG-07] Errors during registration MUST fail with HatchetError - -#### Scenarios - -##### Scenario: Registering a Simple Workflow - -Given a workflow with one task and a layer -When registerWorkflow is executed -Then the workflow is registered with Hatchet and worker starts - -##### Scenario: Registration Fails with Invalid Workflow - -Given a workflow with no tasks -When registerWorkflow is executed -Then it fails with HatchetError - -##### Scenario: Worker Creation Error - -Given a Hatchet client that fails to create a worker -When registerWorkflow is executed -Then it fails with HatchetError containing the cause - ---- - -## Logging Module Specs - -### HatchetLogger Spec - -The HatchetLogger provides automatic log synchronization between Effect.log() and Hatchet UI. - -#### Requirements - -- [REQ-LOG-01] HatchetLogger MUST be created using Logger.make -- [REQ-LOG-02] HatchetLogger MUST detect if HatchetStepContext exists in the fiber -- [REQ-LOG-03] If context exists, logs MUST be sent to ctx.log() -- [REQ-LOG-04] Logs MUST always be printed to console regardless of context -- [REQ-LOG-05] Log level MUST be included in the Hatchet log message - -#### Scenarios - -##### Scenario: Log Within Hatchet Task - -Given an Effect running within a Hatchet task with HatchetStepContext -When Effect.log("message") is called -Then the message appears in Hatchet dashboard via ctx.log() - -##### Scenario: Log Outside Hatchet Task - -Given an Effect running outside a Hatchet task (no HatchetStepContext) -When Effect.log("message") is called -Then the message goes to console only - -##### Scenario: Log Level Included - -Given a log with level "debug" -When HatchetLogger formats the message -Then the output includes the log level label - ---- - -### withHatchetLogger Spec - -The withHatchetLogger function applies the HatchetLogger to an Effect. - -#### Requirements - -- [REQ-WLOG-01] withHatchetLogger MUST accept an Effect and return a new Effect -- [REQ-WLOG-02] withHatchetLogger MUST use Effect.withLogger (not Logger.replace) -- [REQ-WLOG-03] The returned Effect MUST have the same type signature as input - -#### Scenarios - -##### Scenario: Applying Logger to Effect - -Given an Effect -When withHatchetLogger is called -Then it returns an Effect with the HatchetLogger applied - ---- - -## Schema Module Specs - -### getValidatedInput Spec - -The getValidatedInput function validates workflow input against an Effect Schema. - -#### Requirements - -- [REQ-SCHEMA-01] getValidatedInput MUST accept a Schema as parameter -- [REQ-SCHEMA-02] getValidatedInput MUST extract input from HatchetStepContext -- [REQ-SCHEMA-03] getValidatedInput MUST use Schema.decodeUnknown for validation -- [REQ-SCHEMA-04] On validation failure, it MUST fail with Schema.ParseError -- [REQ-SCHEMA-05] On success, it MUST return the parsed and typed input -- [REQ-SCHEMA-06] The Schema type parameter MUST infer the return type - -#### Scenarios - -##### Scenario: Valid Input Passes Validation - -Given a Schema and valid input data -When getValidatedInput is executed -Then it returns the parsed input - -##### Scenario: Invalid Input Fails with ParseError - -Given a Schema and invalid input data -When getValidatedInput is executed -Then it fails with Schema.ParseError - -##### Scenario: Type Inference from Schema - -Given a Schema.Struct with { name: Schema.String } -When getValidatedInput is used -Then the return type includes name: string - ---- - -## Testing Module Specs - -### createMockStepContext Spec - -The createMockStepContext function creates a mock Hatchet context for testing. - -#### Requirements - -- [REQ-MOCK-01] createMockStepContext MUST accept optional input data -- [REQ-MOCK-02] The mock MUST include input property with the provided data -- [REQ-MOCK-03] The mock MUST include parentOutput that returns null -- [REQ-MOCK-04] The mock MUST include log and logger methods (no-op) -- [REQ-MOCK-05] The mock MUST include workflowRunId, workflowName, taskName, retryCount - -#### Scenarios - -##### Scenario: Creating Mock Context with Input - -Given input data { userId: "123" } -When createMockStepContext is called with that input -Then ctx.input returns { userId: "123" } - -##### Scenario: Creating Default Mock Context - -Given no input -When createMockStepContext is called -Then ctx.input returns empty object - ---- - -### runTestTask Spec - -The runTestTask function executes an Effect with a mock context. - -#### Requirements - -- [REQ-RUNTEST-01] runTestTask MUST accept an Effect with HatchetStepContext dependency -- [REQ-RUNTEST-02] runTestTask MUST accept optional mock context -- [REQ-RUNTEST-03] runTestTask MUST provide HatchetStepContext as a service -- [REQ-RUNTEST-04] runTestTask MUST return Exit.Exit for result inspection - -#### Scenarios - -##### Scenario: Running Task with Mock Context - -Given an Effect that yields HatchetStepContext -When runTestTask is executed with mock context -Then the Effect has access to the mock context - -##### Scenario: Test Returns Exit - -Given an Effect that succeeds or fails -When runTestTask is executed -Then the result is wrapped in Exit for assertion - ---- - -## Monorepo Setup Specs - -### project.json Spec - -The Nx project configuration for the hatchet package. - -#### Requirements - -- [REQ-NX-01] The project MUST be named @effectify/hatchet -- [REQ-NX-02] The source root MUST be packages/hatchet/src -- [REQ-NX-03] The build target MUST use @nx/js:tsc -- [REQ-NX-04] The test target MUST run vitest -- [REQ-NX-05] The lint target MUST use nx-oxlint:lint - -#### Scenarios - -##### Scenario: Build Target Executes - -Given nx build @effectify/hatchet -When the command is run -Then it produces output in packages/hatchet/dist - -##### Scenario: Test Target Executes - -Given nx test @effectify/hatchet -When the command is run -Then vitest runs the test suite - ---- - -### package.json Spec - -The package manifest for @effectify/hatchet. - -#### Requirements - -- [REQ-PKG-01] Package name MUST be @effectify/hatchet -- [REQ-PKG-02] Effect MUST be a peerDependency using catalog: -- [REQ-PKG-03] @hatchet-dev/typescript-sdk MUST be a dependency with version 1.21.0 -- [REQ-PKG-04] @effect/vitest MUST be a devDependency -- [REQ-PKG-05] Type MUST be module (ESM) -- [REQ-PKG-06] Exports MUST include "." for main entry - -#### Scenarios - -##### Scenario: Package.json Validates Dependencies - -Given the package.json -When npm or pnpm installs dependencies -Then effect is installed as peerDependency and hatchet-sdk as dependency - ---- - -### tsconfig.json Spec - -TypeScript configuration for the hatchet package. - -#### Requirements - -- [REQ-TS-01] tsconfig.json MUST extend ../../tsconfig.base.json -- [REQ-TS-02] tsconfig.lib.json MUST use composite builds -- [REQ-TS-03] tsconfig.spec.json MUST include test files -- [REQ-TS-04] Paths MUST include @effectify/hatchet for self-references - -#### Scenarios - -##### Scenario: TypeScript Compiles with Composite - -Given tsconfig.lib.json -When tsc builds the package -Then it produces declaration files and build info - ---- - -### vitest.config.ts Spec - -Test configuration for the hatchet package. - -#### Requirements - -- [REQ-VITEST-01] vitest.config.ts MUST use @effect/vitest for equality testers -- [REQ-VITEST-02] setupFiles MUST include setup-tests.ts -- [REQ-VITEST-03] Test include pattern MUST match \*_/_.test.ts -- [REQ-VITEST-04] Aliases MUST resolve @effectify/hatchet to src - -#### Scenarios - -##### Scenario: Tests Use Effect Equality Testers - -Given vitest runs a test with Effect comparisons -When assertions are made -Then @effect/vitest equality testers are applied - ---- - -### nx.json Integration Spec - -The hatchet package must be added to the release configuration. - -#### Requirements - -- [REQ-RELEASE-01] nx.json MUST include hatchet in release.projects array -- [REQ-RELEASE-02] The package MUST be releasable as npm package - -#### Scenarios - -##### Scenario: Release Includes Hatchet Package - -Given nx release is run -When the hatchet project is included -Then it publishes to npm registry - ---- - -## Integration Specs - -### Docker Compose Spec - -Integration tests require a Docker Compose setup with Hatchet and PostgreSQL. - -#### Requirements - -- [REQ-DOCKER-01] docker-compose.yml MUST include postgres service with correct credentials -- [REQ-DOCKER-02] docker-compose.yml MUST include hatchet service -- [REQ-DOCKER-03] Hatchet MUST depend on postgres with health check -- [REQ-DOCKER-04] postgres MUST use healthcheck for dependency conditions -- [REQ-DOCKER-05] DATABASE_URL MUST be configured for postgres connection - -#### Scenarios - -##### Scenario: Docker Compose Starts Successfully - -Given docker-compose.yml -When docker compose up -d is run -Then both postgres and hatchet services start - -##### Scenario: Health Checks Pass - -Given running containers -When health checks are queried -Then both services return healthy status - ---- - -### Integration Tests Spec - -Tests against real Hatchet engine. - -#### Requirements - -- [REQ-INT-01] Integration tests MUST wait for Hatchet to be ready before running -- [REQ-INT-02] Integration tests MUST test workflow registration -- [REQ-INT-03] Integration tests MUST test workflow execution -- [REQ-INT-04] Integration tests MUST verify logs appear in Hatchet -- [REQ-INT-05] Integration tests MUST verify error handling triggers retries - -#### Scenarios - -##### Scenario: Workflow Registration - -Given a defined EffectWorkflow -When registerWorkflow is executed against real Hatchet -Then the workflow appears in Hatchet dashboard - -##### Scenario: Workflow Execution - -Given a registered workflow -When triggered via hatchet.admin.runWorkflow -Then it executes and returns a workflowRunId - -##### Scenario: Task Retries on Error - -Given a task that fails with Effect.fail -When the workflow is executed -Then Hatchet retries the task according to retry configuration - ---- - -## Error Handling Scenarios - -### Error Propagation - -#### Scenario: Effect Failure Becomes Hatchet Error - -Given an Effect that fails with an error -When effectifyTask converts the failure -Then Hatchet receives an exception that triggers its error handling - -#### Scenario: Network Errors During Client Init - -Given network is unavailable -When HatchetClientLive attempts initialization -Then it fails with HatchetError containing the network error - ---- - -## Performance and Resource Management - -### Runtime Disposal - -#### Scenario: ManagedRuntime Cleanup - -Given createEffectifierFromLayer creates a runtime -When the worker shuts down -Then the runtime is disposed to prevent leaks - ---- - -## Summary - -This specification defines 68 requirements across 8 major module categories. Each requirement is testable through the defined scenarios. The package uses verified Effect v4 APIs (ServiceMap.Service, ManagedRuntime.make, Effect.withLogger) and Hatchet SDK v1.21.0 APIs (workflow.task(), ctx.input, ctx.parentOutput()). diff --git a/docs/tasks/hatchet-tasks.md b/docs/tasks/hatchet-tasks.md deleted file mode 100644 index 03a1ff3f..00000000 --- a/docs/tasks/hatchet-tasks.md +++ /dev/null @@ -1,392 +0,0 @@ -# @effectify/hatchet Implementation Tasks - -## Executive Summary - -This document defines the implementation tasks for `@effectify/hatchet`, an Effect v4 integration with Hatchet SDK v1.19.0. The package enables users to write Hatchet workflows as pure `Effect` computations with dependency injection, typed errors, and automatic logging sync. - -**Total Tasks**: 47 tasks across 9 phases - ---- - -## Phase 1: Monorepo Setup - -Set up the Nx package structure following `@effectify/prisma` patterns. - -- [x] [TASK-001] Create `packages/hatchet/` directory structure - - - **Files**: `packages/hatchet/` - - **Depends on**: None - - **Spec ref**: REQ-NX-01, REQ-NX-02 - -- [x] [TASK-002] Create `packages/hatchet/project.json` with Nx configuration - - - **Files**: `packages/hatchet/project.json` - - **Depends on**: TASK-001 - - **Spec ref**: REQ-NX-01, REQ-NX-02, REQ-NX-03, REQ-NX-04, REQ-NX-05 - -- [x] [TASK-003] Create `packages/hatchet/package.json` with dependencies - - - **Files**: `packages/hatchet/package.json` - - **Depends on**: TASK-001 - - **Spec ref**: REQ-PKG-01, REQ-PKG-02, REQ-PKG-03, REQ-PKG-04, REQ-PKG-05, REQ-PKG-06 - -- [x] [TASK-004] Create `packages/hatchet/tsconfig.json` - - - **Files**: `packages/hatchet/tsconfig.json` - - **Depends on**: TASK-001 - - **Spec ref**: REQ-TS-01, REQ-TS-04 - -- [x] [TASK-005] Create `packages/hatchet/tsconfig.lib.json` - - - **Files**: `packages/hatchet/tsconfig.lib.json` - - **Depends on**: TASK-001, TASK-004 - - **Spec ref**: REQ-TS-02 - -- [x] [TASK-006] Create `packages/hatchet/tsconfig.spec.json` - - - **Files**: `packages/hatchet/tsconfig.spec.json` - - **Depends on**: TASK-001, TASK-004 - - **Spec ref**: REQ-TS-03 - -- [x] [TASK-007] Create `packages/hatchet/vitest.config.ts` - - - **Files**: `packages/hatchet/vitest.config.ts` - - **Depends on**: TASK-001 - - **Spec ref**: REQ-VITEST-01, REQ-VITEST-02, REQ-VITEST-03, REQ-VITEST-04 - -- [x] [TASK-008] Create `packages/hatchet/setup-tests.ts` - - - **Files**: `packages/hatchet/setup-tests.ts` - - **Depends on**: TASK-001 - - **Spec ref**: REQ-VITEST-02 - -- [x] [TASK-009] Create `packages/hatchet/src/` directory structure - - - **Files**: `packages/hatchet/src/` - - **Depends on**: TASK-001 - -- [x] [TASK-010] Add `packages/hatchet` to `nx.json` release.projects - - - **Files**: `nx.json` - - **Depends on**: TASK-001 - - **Spec ref**: REQ-RELEASE-01, REQ-RELEASE-02 - -- [x] [TASK-011] Install dependencies with pnpm - - **Files**: N/A - - **Depends on**: TASK-002, TASK-003 - - **Spec ref**: REQ-PKG-02, REQ-PKG-03 - ---- - -## Phase 2: Core Module - -Implement the core services: HatchetError, HatchetConfig, HatchetClientService, and HatchetStepContext. - -- [x] [TASK-012] Create `packages/hatchet/src/core/error.ts` — HatchetError - - - **Files**: `packages/hatchet/src/core/error.ts` - - **Depends on**: TASK-009 - - **Spec ref**: REQ-ERROR-01, REQ-ERROR-02, REQ-ERROR-03, REQ-ERROR-04 - -- [x] [TASK-013] Create `packages/hatchet/src/core/config.ts` — HatchetConfig - - - **Files**: `packages/hatchet/src/core/config.ts` - - **Depends on**: TASK-012 - - **Spec ref**: REQ-CORE-01, REQ-CORE-02, REQ-CORE-03, REQ-CORE-04, REQ-CORE-05, REQ-CORE-06 - -- [x] [TASK-014] Create `packages/hatchet/src/core/client.ts` — HatchetClientService - - - **Files**: `packages/hatchet/src/core/client.ts` - - **Depends on**: TASK-012, TASK-013 - - **Spec ref**: REQ-CLIENT-01, REQ-CLIENT-02, REQ-CLIENT-03, REQ-CLIENT-04, REQ-CLIENT-05 - -- [x] [TASK-015] Create `packages/hatchet/src/core/context.ts` — HatchetStepContext - - - **Files**: `packages/hatchet/src/core/context.ts` - - **Depends on**: TASK-014 - - **Spec ref**: REQ-CONTEXT-01, REQ-CONTEXT-02, REQ-CONTEXT-03, REQ-CONTEXT-04, REQ-CONTEXT-05 - -- [x] [TASK-016] Create `packages/hatchet/src/core/index.ts` — Core exports - - - **Files**: `packages/hatchet/src/core/index.ts` - - **Depends on**: TASK-012, TASK-013, TASK-014, TASK-015 - -- [ ] [TASK-017] Create `packages/hatchet/tests/unit/core.test.ts` — Core unit tests - - **Files**: `packages/hatchet/tests/unit/core.test.ts` - - **Depends on**: TASK-016 - - **Spec ref**: REQ-ERROR-01, REQ-ERROR-02, REQ-ERROR-03, REQ-ERROR-04, REQ-CORE-01, REQ-CORE-02, REQ-CORE-03, REQ-CORE-04, REQ-CORE-05, REQ-CORE-06, REQ-CLIENT-01, REQ-CLIENT-02, REQ-CLIENT-03, REQ-CLIENT-04, REQ-CLIENT-05, REQ-CONTEXT-01, REQ-CONTEXT-02, REQ-CONTEXT-03, REQ-CONTEXT-04, REQ-CONTEXT-05 - ---- - -## Phase 3: Effectifier Module - -Implement the bridge that converts Effect → Promise for Hatchet task execution. - -- [ ] [TASK-018] Create `packages/hatchet/src/effectifier/types.ts` — Internal types - - - **Files**: `packages/hatchet/src/effectifier/types.ts` - - **Depends on**: TASK-015 - -- [ ] [TASK-019] Create `packages/hatchet/src/effectifier/execute.ts` — effectifyTask - - - **Files**: `packages/hatchet/src/effectifier/execute.ts` - - **Depends on**: TASK-018, TASK-015 - - **Spec ref**: REQ-EFFECT-01, REQ-EFFECT-02, REQ-EFFECT-03, REQ-EFFECT-04, REQ-EFFECT-05, REQ-EFFECT-06, REQ-FACTORY-01, REQ-FACTORY-02, REQ-FACTORY-03, REQ-FACTORY-04 - -- [ ] [TASK-020] Create `packages/hatchet/src/effectifier/index.ts` — Effectifier exports - - - **Files**: `packages/hatchet/src/effectifier/index.ts` - - **Depends on**: TASK-018, TASK-019 - -- [x] [TASK-021] Create `packages/hatchet/tests/unit/effectifier.test.ts` — Effectifier tests - - **Files**: `packages/hatchet/tests/unit/effectifier.test.ts` - - **Depends on**: TASK-020 - - **Spec ref**: REQ-EFFECT-01, REQ-EFFECT-02, REQ-EFFECT-03, REQ-EFFECT-04, REQ-EFFECT-05, REQ-EFFECT-06, REQ-FACTORY-01, REQ-FACTORY-02, REQ-FACTORY-03, REQ-FACTORY-04 - ---- - -## Phase 4: Workflow Module - -Implement the declarative workflow builder matching Hatchet's API style. - -- [ ] [TASK-022] Create `packages/hatchet/src/workflow/types.ts` — TaskOptions, WorkflowOptions - - - **Files**: `packages/hatchet/src/workflow/types.ts` - - **Depends on**: TASK-019 - - **Spec ref**: REQ-TASK-02, REQ-WF-01, REQ-WF-02, REQ-WF-03, REQ-WF-04 - -- [ ] [TASK-023] Create `packages/hatchet/src/workflow/workflow.ts` — EffectWorkflow class - - - **Files**: `packages/hatchet/src/workflow/workflow.ts` - - **Depends on**: TASK-022 - - **Spec ref**: REQ-WF-01, REQ-WF-02, REQ-WFTASK-01, REQ-WFTASK-02, REQ-WFTASK-03 - -- [ ] [TASK-024] Create `packages/hatchet/src/workflow/task.ts` — task() function - - - **Files**: `packages/hatchet/src/workflow/task.ts` - - **Depends on**: TASK-022, TASK-023 - - **Spec ref**: REQ-TASK-01, REQ-TASK-02, REQ-TASK-03, REQ-TASK-04 - -- [ ] [TASK-025] Create `packages/hatchet/src/workflow/register.ts` — registerWorkflow() - - - **Files**: `packages/hatchet/src/workflow/register.ts` - - **Depends on**: TASK-019, TASK-022, TASK-023, TASK-024 - - **Spec ref**: REQ-REG-01, REQ-REG-02, REQ-REG-03, REQ-REG-04, REQ-REG-05, REQ-REG-06, REQ-REG-07 - -- [ ] [TASK-026] Create `packages/hatchet/src/workflow/index.ts` — Workflow exports - - - **Files**: `packages/hatchet/src/workflow/index.ts` - - **Depends on**: TASK-022, TASK-023, TASK-024, TASK-025 - -- [ ] [TASK-027] Create `packages/hatchet/tests/unit/workflow.test.ts` — Workflow tests - - **Files**: `packages/hatchet/tests/unit/workflow.test.ts` - - **Depends on**: TASK-026 - - **Spec ref**: REQ-WF-01, REQ-WF-02, REQ-WF-03, REQ-WF-04, REQ-TASK-01, REQ-TASK-02, REQ-TASK-03, REQ-TASK-04, REQ-WFTASK-01, REQ-WFTASK-02, REQ-WFTASK-03, REQ-REG-01, REQ-REG-02, REQ-REG-03, REQ-REG-04, REQ-REG-05, REQ-REG-06, REQ-REG-07 - ---- - -## Phase 5: Logging Module - -Implement automatic log synchronization between Effect.log() and Hatchet UI. - -- [ ] [TASK-028] Create `packages/hatchet/src/logging/hatchet-logger.ts` — HatchetLogger - - - **Files**: `packages/hatchet/src/logging/hatchet-logger.ts` - - **Depends on**: TASK-015 - - **Spec ref**: REQ-LOG-01, REQ-LOG-02, REQ-LOG-03, REQ-LOG-04, REQ-LOG-05, REQ-WLOG-01, REQ-WLOG-02, REQ-WLOG-03 - -- [ ] [TASK-029] Create `packages/hatchet/src/logging/index.ts` — Logging exports - - - **Files**: `packages/hatchet/src/logging/index.ts` - - **Depends on**: TASK-028 - -- [ ] [TASK-030] Create `packages/hatchet/tests/unit/logger.test.ts` — Logger tests - - **Files**: `packages/hatchet/tests/unit/logger.test.ts` - - **Depends on**: TASK-029 - - **Spec ref**: REQ-LOG-01, REQ-LOG-02, REQ-LOG-03, REQ-LOG-04, REQ-LOG-05, REQ-WLOG-01, REQ-WLOG-02, REQ-WLOG-03 - ---- - -## Phase 6: Schema Module - -Implement input validation utilities using Effect Schema. - -- [ ] [TASK-031] Create `packages/hatchet/src/schema/get-validated-input.ts` — getValidatedInput - - - **Files**: `packages/hatchet/src/schema/get-validated-input.ts` - - **Depends on**: TASK-015 - - **Spec ref**: REQ-SCHEMA-01, REQ-SCHEMA-02, REQ-SCHEMA-03, REQ-SCHEMA-04, REQ-SCHEMA-05, REQ-SCHEMA-06 - -- [ ] [TASK-032] Create `packages/hatchet/src/schema/index.ts` — Schema exports - - - **Files**: `packages/hatchet/src/schema/index.ts` - - **Depends on**: TASK-031 - -- [ ] [TASK-033] Create `packages/hatchet/tests/unit/schema.test.ts` — Schema tests - - **Files**: `packages/hatchet/tests/unit/schema.test.ts` - - **Depends on**: TASK-032 - - **Spec ref**: REQ-SCHEMA-01, REQ-SCHEMA-02, REQ-SCHEMA-03, REQ-SCHEMA-04, REQ-SCHEMA-05, REQ-SCHEMA-06 - ---- - -## Phase 7: Testing Module - -Implement testing utilities for unit testing tasks. - -- [ ] [TASK-034] Create `packages/hatchet/src/testing/mock-context.ts` — Test utilities - - - **Files**: `packages/hatchet/src/testing/mock-context.ts` - - **Depends on**: TASK-015 - - **Spec ref**: REQ-MOCK-01, REQ-MOCK-02, REQ-MOCK-03, REQ-MOCK-04, REQ-MOCK-05, REQ-RUNTEST-01, REQ-RUNTEST-02, REQ-RUNTEST-03, REQ-RUNTEST-04 - -- [ ] [TASK-035] Create `packages/hatchet/src/testing/index.ts` — Testing exports - - **Files**: `packages/hatchet/src/testing/index.ts` - - **Depends on**: TASK-034 - ---- - -## Phase 8: Public API - -Create the main public exports for the package. - -- [ ] [TASK-036] Create `packages/hatchet/src/index.ts` — Main public exports - - - **Files**: `packages/hatchet/src/index.ts` - - **Depends on**: TASK-016, TASK-020, TASK-026, TASK-029, TASK-032, TASK-035 - -- [ ] [TASK-037] Verify build passes with `nx build @effectify/hatchet` - - - **Files**: N/A - - **Depends on**: TASK-036, TASK-010, TASK-011 - -- [ ] [TASK-038] Verify tests pass with `nx test @effectify/hatchet` - - - **Files**: N/A - - **Depends on**: TASK-017, TASK-021, TASK-027, TASK-030, TASK-033, TASK-035, TASK-036 - -- [ ] [TASK-039] Verify lint passes with `nx lint @effectify/hatchet` - - **Files**: N/A - - **Depends on**: TASK-036 - ---- - -## Phase 9: Integration Tests - -Create integration tests against real Hatchet engine. - -- [ ] [TASK-040] Create `packages/hatchet/tests/integration/docker-compose.yml` - - - **Files**: `packages/hatchet/tests/integration/docker-compose.yml` - - **Depends on**: TASK-036 - - **Spec ref**: REQ-DOCKER-01, REQ-DOCKER-02, REQ-DOCKER-03, REQ-DOCKER-04, REQ-DOCKER-05 - -- [ ] [TASK-041] Create `packages/hatchet/tests/integration/workflow.test.ts` — Integration tests - - - **Files**: `packages/hatchet/tests/integration/workflow.test.ts` - - **Depends on**: TASK-040 - - **Spec ref**: REQ-INT-01, REQ-INT-02, REQ-INT-03, REQ-INT-04, REQ-INT-05 - -- [ ] [TASK-042] Run integration tests with Docker Compose - - **Files**: N/A - - **Depends on**: TASK-040, TASK-041 - ---- - -## Phase 10: Documentation & Release - -Finalize package for release. - -- [x] [TASK-043] Create `packages/hatchet/README.md` — Package documentation - - - **Files**: `packages/hatchet/README.md` - - **Depends on**: TASK-036, TASK-038 - -- [ ] [TASK-044] Verify package.json exports are correct - - - **Files**: `packages/hatchet/package.json` - - **Depends on**: TASK-003, TASK-036 - -- [ ] [TASK-045] Create TypeScript declaration files verification - - - **Files**: N/A - - **Depends on**: TASK-037 - -- [ ] [TASK-046] Verify release configuration in nx.json - - - **Files**: `nx.json` - - **Depends on**: TASK-010 - -- [ ] [TASK-047] Test nx release dry-run for hatchet package - - **Files**: N/A - - **Depends on**: TASK-046 - ---- - -## Task Dependencies Graph - -``` -Phase 1: Monorepo Setup -├── TASK-001 ─┬─► TASK-002 ─┬─► TASK-003 ─┬─► TASK-011 -│ │ │ │ -│ │ │ └─► TASK-010 ──► TASK-046 -│ │ │ -│ │ ├─► TASK-004 ─┬─► TASK-005 -│ │ │ └─► TASK-006 -│ │ │ -│ │ └─► TASK-007 -│ │ -│ └─► TASK-008 -│ -└─► TASK-009 ─┬─► TASK-012 ──► TASK-013 ──► TASK-014 ──► TASK-015 ──► TASK-016 - │ │ - ├─► TASK-018 ──► TASK-019 ──┬─► TASK-020 ──► TASK-021 ──┤ - │ │ │ - │ ├─► TASK-022 ──► TASK-023 ──► TASK-024 ──► TASK-025 ──► TASK-026 ──► TASK-027 - │ │ │ - │ └─► TASK-028 ──► TASK-029 ──► TASK-030 - │ - ├─► TASK-031 ──► TASK-032 ──► TASK-033 - │ - └─► TASK-034 ──► TASK-035 - -Phase 8: Public API -TASK-016, TASK-020, TASK-026, TASK-029, TASK-032, TASK-035 ──► TASK-036 ──┬─► TASK-037 - ├─► TASK-038 - └─► TASK-039 - -Phase 9: Integration Tests -TASK-036 ──► TASK-040 ──► TASK-041 ──► TASK-042 - -Phase 10: Documentation -TASK-036, TASK-038 ──► TASK-043 ──► TASK-044 ──► TASK-045 ──► TASK-047 -``` - ---- - -## Completion Criteria - -- [ ] All 47 tasks completed -- [ ] Build passes: `nx build @effectify/hatchet` -- [ ] Unit tests pass (>90% coverage): `nx test @effectify/hatchet` -- [ ] Lint passes: `nx lint @effectify/hatchet` -- [ ] Integration tests pass with Docker Compose -- [ ] Release configuration verified in nx.json -- [ ] README.md complete with usage examples - ---- - -## Key Technical Decisions - -| Decision | Rationale | -| --------------------------------------- | --------------------------------------------- | -| `ServiceMap.Service` over `Context.Tag` | Context module doesn't exist in Effect v4 | -| `ManagedRuntime.make(layer)` | `Effect.runtime()` doesn't exist in v4 | -| `Effect.withLogger(effect, logger)` | `Logger.replace` doesn't exist in v4 | -| `workflow.task()` not `workflow.step()` | SDK v1.19.0 uses task terminology | -| `ctx.input` property | SDK v1.19.0 has input as property, not method | - ---- - -**Document Version**: 1.0.0\ -**Created**: March 2026\ -**Phase**: Tasks Breakdown diff --git a/packages/chat/domain/src/auth.test.ts b/packages/chat/domain/src/auth.test.ts new file mode 100644 index 00000000..3e20f88c --- /dev/null +++ b/packages/chat/domain/src/auth.test.ts @@ -0,0 +1,57 @@ +import * as Schema from "effect/Schema" +import { describe, expect, it } from "vitest" +import { LoginSchema, RegisterSchema } from "./auth.js" + +describe("chat auth schemas", () => { + it("keeps the historical login constraints", () => { + expect( + Schema.decodeSync(LoginSchema)({ + email: "ada@example.com", + password: "abc", + }), + ).toEqual({ email: "ada@example.com", password: "abc" }) + + expect(() => + Schema.decodeSync(LoginSchema)({ + email: "invalid", + password: "abc", + }), + ).toThrow() + expect(() => + Schema.decodeSync(LoginSchema)({ + email: "ada@example.com", + password: "ab", + }), + ).toThrow() + }) + + it("keeps registration lengths and reports password mismatch on confirmPassword", () => { + const input = { + name: "Ada", + email: "ada@example.com", + password: "secret", + confirmPassword: "different", + } + const result = Schema.toStandardSchemaV1(RegisterSchema)["~standard"].validate(input) + + if (result instanceof Promise) { + throw new Error("expected synchronous schema validation") + } + + expect(result.issues).toContainEqual( + expect.objectContaining({ + message: "Passwords do not match", + path: ["confirmPassword"], + }), + ) + + expect(() => Schema.decodeSync(RegisterSchema)({ ...input, name: "A" })).toThrow() + expect(() => + Schema.decodeSync(RegisterSchema)({ + ...input, + password: "short", + confirmPassword: "short", + }), + ).toThrow() + }) +}) diff --git a/packages/chat/domain/src/auth.ts b/packages/chat/domain/src/auth.ts index d1e42920..97f4dd27 100644 --- a/packages/chat/domain/src/auth.ts +++ b/packages/chat/domain/src/auth.ts @@ -1,14 +1,20 @@ -// Stub for auth schemas - Effect v4 migration deferred +import { Email } from "@effectify/shared-domain/email.js" import * as Schema from "effect/Schema" export const LoginSchema = Schema.Struct({ - email: Schema.String, - password: Schema.String, + email: Email, + password: Schema.String.check(Schema.isMinLength(3)), }) export const RegisterSchema = Schema.Struct({ - name: Schema.String, - email: Schema.String, - password: Schema.String, - confirmPassword: Schema.String, -}) + name: Schema.String.check(Schema.isMinLength(2)), + email: Email, + password: Schema.String.check(Schema.isMinLength(6)), + confirmPassword: Schema.String.check(Schema.isMinLength(6)), +}).check( + Schema.makeFilter((input) => + input.password === input.confirmPassword + ? undefined + : { path: ["confirmPassword"], issue: "Passwords do not match" }, + ), +) diff --git a/packages/chat/domain/src/index.ts b/packages/chat/domain/src/index.ts index cff1fde6..7d50d9f1 100644 --- a/packages/chat/domain/src/index.ts +++ b/packages/chat/domain/src/index.ts @@ -1,28 +1,5 @@ -// Ultra-simplified stub for chat-domain -// Full v4 migration deferred - this enables examples to compile - -export const MessageId = { - make: (id: string) => id as string & { readonly __brand: unique symbol }, -} - -export type MessageId = ReturnType - -export interface Message { - id: MessageId - body: string - createdAt: Date - readAt: Date | null -} - -// Stub services -export const MessagesService = { - getMessages: () => Promise.resolve([] as Message[]), - sendMarkAsReadBatch: (_batch: string[]) => Promise.resolve(), -} - -export const NetworkMonitor = { - isOnline: () => true, - whenOpen: (fn: () => T) => fn(), -} - -export const Live = {} +export * as Auth from "./auth.js" +export * as Layer from "./layer.js" +export * as Message from "./message.js" +export * as MessageService from "./message-service.js" +export * as NetworkMonitorService from "./message-service.js" diff --git a/packages/chat/domain/src/layer.ts b/packages/chat/domain/src/layer.ts index bd2f23a1..8835e93c 100644 --- a/packages/chat/domain/src/layer.ts +++ b/packages/chat/domain/src/layer.ts @@ -1,4 +1,8 @@ -// Stub for layer - Effect v4 migration deferred import * as Layer from "effect/Layer" +import * as Logger from "effect/Logger" +import { MessagesServiceLive, NetworkMonitorLive } from "./message-service.js" -export const Live = Layer.empty +export const Live = MessagesServiceLive.pipe( + Layer.provideMerge(NetworkMonitorLive), + Layer.provide(Logger.layer([Logger.consolePretty()])), +) diff --git a/packages/chat/domain/src/message-service.test.ts b/packages/chat/domain/src/message-service.test.ts new file mode 100644 index 00000000..08aea92c --- /dev/null +++ b/packages/chat/domain/src/message-service.test.ts @@ -0,0 +1,95 @@ +import * as Chunk from "effect/Chunk" +import * as DateTime from "effect/DateTime" +import * as Effect from "effect/Effect" +import * as Fiber from "effect/Fiber" +import * as Latch from "effect/Latch" +import * as Layer from "effect/Layer" +import * as Logger from "effect/Logger" +import * as SubscriptionRef from "effect/SubscriptionRef" +import * as TestClock from "effect/testing/TestClock" +import { afterAll, describe, expect, it } from "vitest" +import { MessageId } from "./message.js" +import { MessagesService, MessagesServiceLive, NetworkMonitor, NetworkMonitorLive } from "./message-service.js" + +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + +afterAll(() => { + if (originalWindow === undefined) { + Reflect.deleteProperty(globalThis, "window") + } else { + Object.defineProperty(globalThis, "window", originalWindow) + } +}) + +const testLayer = (latch: Latch.Latch, isOnline: boolean) => { + const ref = Effect.runSync(SubscriptionRef.make(isOnline)) + const networkMonitor = Layer.succeed(NetworkMonitor)({ latch, ref }) + + return Layer.mergeAll(MessagesServiceLive.pipe(Layer.provide(networkMonitor)), TestClock.layer({}), Logger.layer([])) +} + +describe("NetworkMonitor", () => { + it("starts offline and opens its ref and latch on the online event", async () => { + const target = new EventTarget() + const navigator = { onLine: false } + Object.defineProperty(target, "navigator", { value: navigator }) + Object.defineProperty(globalThis, "window", { + configurable: true, + value: target, + }) + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const monitor = yield* NetworkMonitor + expect(yield* SubscriptionRef.get(monitor.ref)).toBe(false) + expect(monitor.latch.isOpen()).toBe(false) + + navigator.onLine = true + target.dispatchEvent(new Event("online")) + yield* Effect.sleep("10 millis") + + expect(yield* SubscriptionRef.get(monitor.ref)).toBe(true) + expect(monitor.latch.isOpen()).toBe(true) + }).pipe(Effect.provide(NetworkMonitorLive)), + ).pipe(Effect.provide(Logger.layer([]))), + ) + }) +}) + +describe("MessagesService", () => { + it("returns the historical sample conversation as domain messages", async () => { + const latch = Latch.makeUnsafe(true) + + await Effect.runPromise( + Effect.gen(function* () { + const fiber = yield* MessagesService.use((service) => service.getMessages()).pipe(Effect.forkChild) + yield* TestClock.adjust("3 seconds") + const messages = yield* Fiber.join(fiber) + + expect(messages).toHaveLength(30) + expect(messages[0]?.id).toBe("1") + expect(messages[0]?.body).toBe("Hey there! How are you doing today?") + expect(DateTime.isUtc(messages[0]?.createdAt)).toBe(true) + expect(messages.every((message) => message.readAt === null)).toBe(true) + }).pipe(Effect.provide(testLayer(latch, true))), + ) + }) + + it("gates mark-as-read batches until the network latch opens", async () => { + const latch = Latch.makeUnsafe(false) + + await Effect.runPromise( + Effect.gen(function* () { + const fiber = yield* MessagesService.sendMarkAsReadBatch(Chunk.make(MessageId.make("1"))).pipe(Effect.forkChild) + + yield* TestClock.adjust("3 seconds") + expect(fiber.pollUnsafe()).toBeUndefined() + + yield* latch.open + yield* TestClock.adjust("3 seconds") + yield* Fiber.join(fiber) + }).pipe(Effect.provide(testLayer(latch, false))), + ) + }) +}) diff --git a/packages/chat/domain/src/message-service.ts b/packages/chat/domain/src/message-service.ts new file mode 100644 index 00000000..ee535a38 --- /dev/null +++ b/packages/chat/domain/src/message-service.ts @@ -0,0 +1,139 @@ +import * as Array from "effect/Array" +import * as Chunk from "effect/Chunk" +import * as Context from "effect/Context" +import * as DateTime from "effect/DateTime" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Latch from "effect/Latch" +import * as Layer from "effect/Layer" +import * as Queue from "effect/Queue" +import * as Random from "effect/Random" +import * as Schedule from "effect/Schedule" +import * as Stream from "effect/Stream" +import * as SubscriptionRef from "effect/SubscriptionRef" +import { Message, MessageId, type Message as MessageType } from "./message.js" + +const sampleMessageBodies = [ + "Hey there! How are you doing today?", + "I'm doing great, thanks for asking! How about you?", + "Pretty good! Just finished my morning coffee.", + "Nice! I'm still working on mine. Did you see the weather forecast?", + "Yeah, looks like rain later today.", + "Perfect weather for staying in and coding!", + "Absolutely! What are you working on these days?", + "Building a chat application with React and TypeScript", + "That sounds interesting! How's it going so far?", + "Pretty well! Just working on the UI components now.", + "Are you using any UI libraries?", + "Yeah, I'm using Tailwind CSS for styling", + "Nice choice! I love Tailwind's utility-first approach", + "Me too! It makes styling so much faster", + "Have you tried any component libraries with it?", + "I've been looking at shadcn/ui actually", + "That's a great choice! Very customizable", + "Yeah, I like how it's not a dependency", + "Are you planning to add any real-time features?", + "Definitely! Thinking about using WebSocket", + "Have you worked with WebSocket before?", + "A little bit, but I'm excited to learn more", + "That's the best way to learn - by doing!", + "Exactly! It's been fun so far", + "Oh, looks like it started raining", + "Perfect timing for coding, just like we said!", + "Absolutely! Time to grab another coffee", + "Good idea! I should do the same", + "Talk to you later then?", + "Definitely! Enjoy your coffee!", +] + +const sampleMessages = Array.makeBy(30, (index) => + Message.make({ + id: MessageId.make(`${index + 1}`), + body: sampleMessageBodies[index], + createdAt: DateTime.makeUnsafe("2024-03-20T10:00:00Z").pipe( + DateTime.add({ + minutes: index * 2, + }), + ), + readAt: null, + }), +) + +const makeNetworkMonitor = Effect.suspend(() => { + const target = window + + return Effect.gen(function* () { + const isOnline = target.navigator.onLine + const latch = yield* Latch.make(isOnline) + yield* Effect.log("Created NetworkMonitor") + + const ref = yield* SubscriptionRef.make(isOnline) + const ready = yield* Deferred.make() + const changes = Stream.callback((queue) => { + const online = () => Queue.offerUnsafe(queue, true) + const offline = () => Queue.offerUnsafe(queue, false) + + return Effect.acquireRelease( + Effect.sync(() => { + target.addEventListener("online", online) + target.addEventListener("offline", offline) + }).pipe(Effect.andThen(Deferred.succeed(ready, undefined))), + () => + Effect.sync(() => { + target.removeEventListener("online", online) + target.removeEventListener("offline", offline) + }), + ) + }) + + yield* changes.pipe( + Stream.tap((isOnline) => + (isOnline ? latch.open : latch.close).pipe(Effect.andThen(SubscriptionRef.set(ref, isOnline))), + ), + Stream.runDrain, + Effect.forkScoped, + ) + yield* Deferred.await(ready) + + return { latch, ref } + }) +}) + +export class NetworkMonitor extends Context.Service()("@effectify/chat-domain/NetworkMonitor", { + make: makeNetworkMonitor, +}) {} + +export const NetworkMonitorLive = Layer.effect(NetworkMonitor)(NetworkMonitor.make) + +export class MessagesService extends Context.Service()("@effectify/chat-domain/MessagesService", { + make: Effect.gen(function* () { + const networkMonitor = yield* NetworkMonitor + + return { + getMessages: () => + Effect.gen(function* () { + const sleepFor = yield* Random.nextBetween(1000, 2500) + yield* Effect.sleep(`${sleepFor} millis`) + return sampleMessages + }), + + sendMarkAsReadBatch: (batch: Chunk.Chunk) => + Effect.gen(function* () { + const sleepFor = yield* Random.nextBetween(1000, 2500) + yield* Effect.sleep(`${sleepFor} millis`) + return yield* Effect.log(`Batched: ${Chunk.join(batch, ", ")}`) + }).pipe( + networkMonitor.latch.whenOpen, + Effect.retry({ + times: 3, + schedule: Schedule.exponential("500 millis", 2), + }), + ), + } + }), +}) { + static readonly sendMarkAsReadBatch = (batch: Chunk.Chunk) => + this.use((service) => service.sendMarkAsReadBatch(batch)) +} + +export const MessagesServiceLive = Layer.effect(MessagesService)(MessagesService.make) diff --git a/packages/chat/domain/src/message.test.ts b/packages/chat/domain/src/message.test.ts new file mode 100644 index 00000000..dcb71b61 --- /dev/null +++ b/packages/chat/domain/src/message.test.ts @@ -0,0 +1,42 @@ +import * as DateTime from "effect/DateTime" +import * as Schema from "effect/Schema" +import { describe, expect, it } from "vitest" +import * as Domain from "./index.js" +import { Message, MessageId } from "./message.js" + +describe("Message", () => { + it("keeps the barrel wired to the canonical message and service modules", () => { + expect(Domain.Message.MessageId).toBe(MessageId) + expect(Domain.Message.Message).toBe(Message) + expect(Domain.NetworkMonitorService.NetworkMonitor).toBe(Domain.MessageService.NetworkMonitor) + }) + + it("uses the canonical MessageId brand and DateTime.Utc values", () => { + const createdAt = DateTime.makeUnsafe("2024-03-20T10:00:00Z") + const message = Message.make({ + id: MessageId.make("message-1"), + body: "Hello", + createdAt, + readAt: null, + }) + + expect(message.id).toBe("message-1") + expect(DateTime.isUtc(message.createdAt)).toBe(true) + expect(message.readAt).toBeNull() + + message.readAt = DateTime.add(createdAt, { minutes: 1 }) + expect(message.readAt === null ? false : DateTime.isUtc(message.readAt)).toBe(true) + }) + + it("rejects unbranded input shapes that do not satisfy the runtime schemas", () => { + expect(() => Schema.decodeUnknownSync(MessageId)(42)).toThrow() + expect(() => + Schema.decodeUnknownSync(Message)({ + id: "message-1", + body: "Hello", + createdAt: new Date("2024-03-20T10:00:00Z"), + readAt: null, + }), + ).toThrow() + }) +}) diff --git a/packages/chat/domain/src/message.ts b/packages/chat/domain/src/message.ts index f759451f..97f24ec4 100644 --- a/packages/chat/domain/src/message.ts +++ b/packages/chat/domain/src/message.ts @@ -1,7 +1,12 @@ -// Stub for message types - Effect v4 migration deferred -export interface Message { - id: string - body: string - createdAt: Date - readAt: Date | null -} +import * as Schema from "effect/Schema" + +export const MessageId = Schema.String.pipe(Schema.brand("MessageId")) +export type MessageId = typeof MessageId.Type + +export const Message = Schema.Struct({ + id: MessageId, + body: Schema.String, + createdAt: Schema.DateTimeUtc, + readAt: Schema.NullOr(Schema.DateTimeUtc).pipe(Schema.mutableKey), +}) +export type Message = typeof Message.Type diff --git a/packages/chat/react/src/services/message-namespace.ts b/packages/chat/react/src/services/message-namespace.ts index ec8f6d13..8b5dfb32 100644 --- a/packages/chat/react/src/services/message-namespace.ts +++ b/packages/chat/react/src/services/message-namespace.ts @@ -1,36 +1,137 @@ -// FIXME: Temporary stub - Effect v4 API compatibility issues -// Original implementation commented out due to Effect v4 type changes -// TODO: Migrate to Effect v4 stable APIs when available - import type * as Message from "@effectify/chat-domain/message.js" +import { MessagesService } from "@effectify/chat-domain/message-service.js" import { createQueryKey } from "@effectify/react-query" -import { useEffectQuery } from "./tanstack-query.js" +import * as Chunk from "effect/Chunk" +import * as DateTime from "effect/DateTime" import * as Effect from "effect/Effect" +import * as Fiber from "effect/Fiber" +import * as Queue from "effect/Queue" +import * as Stream from "effect/Stream" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { queryClient, useEffectQuery, useRuntime } from "./tanstack-query.js" + +const isFullyVisible = (element: Element) => { + const rect = element.getBoundingClientRect() + return rect.top >= 0 && rect.left >= 0 && rect.bottom <= window.innerHeight && rect.right <= window.innerWidth +} export namespace MessagesOperations { - const messagesQueryKey = createQueryKey( - "MessagesOperations.useMessagesQuery", - ) + const messagesQueryKey = createQueryKey("MessagesOperations.useMessagesQuery") export const useMessagesQuery = () => { return useEffectQuery({ queryKey: messagesQueryKey(), - queryFn: () => { - // FIXME: Implement with v4 compatible APIs - console.warn( - "MessagesOperations.useMessagesQuery - stub implementation", - ) - return Effect.succeed([] as Message.Message[]) - }, - staleTime: 6500, // 6.5 seconds in millis + queryFn: () => MessagesService.use((service) => service.getMessages()), + staleTime: 6500, }) } - export const useMarkMessagesAsRead = (_messages: Message.Message[]) => { - // FIXME: Implement with v4 compatible APIs - console.warn( - "MessagesOperations.useMarkMessagesAsRead - stub implementation", + export const useMarkMessagesAsRead = (messages: Message.Message[]) => { + const runtime = useRuntime() + const queue = useMemo(() => Effect.runSync(Queue.unbounded()), []) + const offeredIds = useRef(new Set()) + + useEffect(() => { + const streamFiber = Stream.fromQueue(queue).pipe( + Stream.tap((value) => Effect.log(`Queued up ${value}`)), + Stream.groupedWithin(25, "5 seconds"), + Stream.tap((batch) => Effect.log(`Batching: ${batch.join(", ")}`)), + Stream.mapEffect((batch) => MessagesService.sendMarkAsReadBatch(Chunk.fromIterable(batch))), + Stream.runDrain, + Effect.ignoreCause, + runtime.runFork, + ) + + return () => { + runtime.runFork(Fiber.interrupt(streamFiber)) + } + }, [queue, runtime]) + + const unreadMessages = useMemo(() => messages.filter((message) => message.readAt === null), [messages]) + + const offer = useCallback( + (id: Message.MessageId) => { + if (offeredIds.current.has(id) || !Queue.offerUnsafe(queue, id)) { + return false + } + + offeredIds.current.add(id) + const readAt: DateTime.Utc = DateTime.nowUnsafe() + queryClient.setQueryData(messagesQueryKey(), (currentMessages) => { + if (currentMessages === undefined) { + return currentMessages + } + + return currentMessages.map((message) => + message.id === id && message.readAt === null ? { ...message, readAt } : message, + ) + }) + return true + }, + [queue], ) - return { observer: null as IntersectionObserver | null } + + const observerRef = useRef(null) + + useEffect(() => { + const handleFocus = () => { + if (!document.hasFocus()) { + return + } + + const elements = document.querySelectorAll("[data-message-id]") + unreadMessages.forEach((message) => { + const element = Array.from(elements).find( + (candidate) => candidate.getAttribute("data-message-id") === message.id, + ) + if (element !== undefined && isFullyVisible(element) && offer(message.id)) { + observerRef.current?.unobserve(element) + } + }) + } + + window.addEventListener("focus", handleFocus) + return () => { + window.removeEventListener("focus", handleFocus) + } + }, [offer, unreadMessages]) + + const [observer, setObserver] = useState(null) + + useEffect(() => { + if (typeof IntersectionObserver === "undefined") { + return + } + + const nextObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!(entry.isIntersecting && entry.intersectionRatio >= 1 && document.hasFocus())) { + return + } + + const messageId = entry.target.getAttribute("data-message-id") + const message = unreadMessages.find((candidate) => candidate.id === messageId) + if (message !== undefined) { + offer(message.id) + nextObserver.unobserve(entry.target) + } + }) + }, + { threshold: 1 }, + ) + + observerRef.current = nextObserver + setObserver(nextObserver) + + return () => { + nextObserver.disconnect() + if (observerRef.current === nextObserver) { + observerRef.current = null + } + } + }, [offer, unreadMessages]) + + return { observer } } } diff --git a/packages/chat/solid/components.json b/packages/chat/solid/components.json deleted file mode 100644 index 98c11c0c..00000000 --- a/packages/chat/solid/components.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://shadcn-solid.com/schema.json", - "tailwind": { - "config": "tailwind.config.cjs", - "css": { - "path": "src/index.css", - "variable": true - }, - "color": "slate", - "prefix": "" - }, - "alias": { - "@": ".", - "lib": "@/", - "component": "@/components/primitives", - "ui": "@/components/primitives", - "cn": "@/utils/cn" - } -} diff --git a/packages/chat/solid/package.json b/packages/chat/solid/package.json deleted file mode 100644 index c319ab5c..00000000 --- a/packages/chat/solid/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@effectify/chat-solid", - "version": "0.0.0", - "description": "", - "repository": { - "type": "git", - "url": "https://github.com/devx-op/effectify", - "directory": "packages/chat/solid" - }, - "type": "module", - "license": "MIT", - "exports": { - "./components/*": "./src/components/*.tsx", - "./services/*": "./src/services/*.ts" - }, - "dependencies": { - "@effectify/chat-domain": "workspace:*", - "effect": "catalog:", - "@effectify/solid-query": "workspace:*", - "@kobalte/core": "catalog:", - "solid-js": "catalog:", - "@tanstack/solid-query": "catalog:", - "@tanstack/solid-form": "catalog:", - "lucide-solid": "catalog:" - }, - "devDependencies": {}, - "peerDependencies": {}, - "optionalDependencies": {} -} diff --git a/packages/chat/solid/postcss.config.mjs b/packages/chat/solid/postcss.config.mjs deleted file mode 100644 index a7f73a2d..00000000 --- a/packages/chat/solid/postcss.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -export default { - plugins: { - '@tailwindcss/postcss': {}, - }, -} diff --git a/packages/chat/solid/project.json b/packages/chat/solid/project.json deleted file mode 100644 index 5b45e466..00000000 --- a/packages/chat/solid/project.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "../../../node_modules/nx/schemas/project-schema.json", - "name": "@effectify/chat-solid", - "sourceRoot": "packages/chat/solid/src", - "projectType": "library", - "tags": ["chat"], - "targets": { - "lint": { - "executor": "nx-oxlint:lint", - "outputs": ["{options.outputFile}"], - "options": { - "lintFilePatterns": ["packages/chat/solid/**/*.{ts,tsx,js,jsx}"] - } - } - } -} diff --git a/packages/chat/solid/src/components/chat/chat-container.tsx b/packages/chat/solid/src/components/chat/chat-container.tsx deleted file mode 100644 index d012b6bb..00000000 --- a/packages/chat/solid/src/components/chat/chat-container.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type * as Message from "@effectify/chat-domain/message.js" -import { Button } from "@effectify/solid-ui/components/primitives/button" -import { Center } from "@effectify/solid-ui/components/primitives/center" -import { Flex } from "@effectify/solid-ui/components/primitives/flex" -import { VStack } from "@effectify/solid-ui/components/primitives/stack" -import type { UseQueryResult } from "@tanstack/solid-query" -import { AlertCircle } from "lucide-solid" -import type { Component } from "solid-js" -import { MessagesOperations } from "./../../services/message-namespace.js" -import { MessageList } from "./message-list.js" -import { MessageListSkeleton } from "./message-list-skeleton.js" - -const ErrorState: Component<{ messagesQuery: UseQueryResult }> = ({ messagesQuery }) => { - return ( - - - -
-

Error loading messages

-

Something went wrong. Please try again.

-
- - -
- ) -} - -export const ChatContainer: Component = () => { - const messagesQuery = MessagesOperations.useMessagesQuery() - - return ( - -
-

Messages

-
- - - {(() => { - if (messagesQuery.isLoading) { - return - } - - if (messagesQuery.isSuccess) { - return - } - - return } /> - })()} - -
- ) -} diff --git a/packages/chat/solid/src/components/chat/index.ts b/packages/chat/solid/src/components/chat/index.ts deleted file mode 100644 index a34e31c1..00000000 --- a/packages/chat/solid/src/components/chat/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./chat-container.jsx" -export * from "./message-bubble.jsx" -export * from "./message-list.jsx" diff --git a/packages/chat/solid/src/components/chat/mesage-bubble-skeleton.tsx b/packages/chat/solid/src/components/chat/mesage-bubble-skeleton.tsx deleted file mode 100644 index d700370c..00000000 --- a/packages/chat/solid/src/components/chat/mesage-bubble-skeleton.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Flex } from "@effectify/solid-ui/components/primitives/flex" -import { VStack } from "@effectify/solid-ui/components/primitives/stack" -import type { Component } from "solid-js" - -interface MessageBubbleSkeletonProps { - width?: number -} - -export const MessageBubbleSkeleton: Component = ({ width = 200 }) => { - return ( - - -
- -
- - -
-
- - -
- - - ) -} diff --git a/packages/chat/solid/src/components/chat/message-bubble.tsx b/packages/chat/solid/src/components/chat/message-bubble.tsx deleted file mode 100644 index 73cf6fa7..00000000 --- a/packages/chat/solid/src/components/chat/message-bubble.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type * as Message from "@effectify/chat-domain/message.js" -import { cn } from "@effectify/solid-ui/lib/utils" -import { DateTime } from "effect" -import { CheckCheckIcon } from "lucide-solid" -import type { Component, JSX } from "solid-js" - -type Props = { - message: Message.Message -} - -export const MessageBubble: Component> = (props) => { - return ( -
-
-

{props.message.body}

- -
- - {props.message.createdAt.pipe( - DateTime.formatLocal({ - hour: "2-digit", - minute: "2-digit", - }), - )} - - - -
-
-
- ) -} diff --git a/packages/chat/solid/src/components/chat/message-list-skeleton.tsx b/packages/chat/solid/src/components/chat/message-list-skeleton.tsx deleted file mode 100644 index 42ec1599..00000000 --- a/packages/chat/solid/src/components/chat/message-list-skeleton.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { VStack } from "@effectify/solid-ui/components/primitives/stack" -import type { Component } from "solid-js" -import { MessageBubbleSkeleton } from "./mesage-bubble-skeleton.js" - -export const MessageListSkeleton: Component = () => { - const widths = [180, 260, 200, 180, 260, 200, 180, 260, 200] - - return ( - - {widths.map((width) => )} - - ) -} diff --git a/packages/chat/solid/src/components/chat/message-list.tsx b/packages/chat/solid/src/components/chat/message-list.tsx deleted file mode 100644 index 8d08f68c..00000000 --- a/packages/chat/solid/src/components/chat/message-list.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type * as Message from "@effectify/chat-domain/message.ts" - -import { VStack } from "@effectify/solid-ui/components/primitives/stack" -import type { Component } from "solid-js" -import { MessagesOperations } from "./../../services/message-namespace.js" -import { MessageBubble } from "./message-bubble.jsx" - -type Props = { - messages: Message.Message[] -} - -export const MessageList: Component = ({ messages }) => { - const { observer } = MessagesOperations.useMarkMessagesAsRead(messages) - - return ( - - {messages.map((message) => ( - { - if (el !== null && message.readAt === null) { - requestAnimationFrame(() => { - ;(observer as unknown as IntersectionObserver)?.observe(el) - }) - } - }} - /> - ))} - - ) -} diff --git a/packages/chat/solid/src/components/login-form.tsx b/packages/chat/solid/src/components/login-form.tsx deleted file mode 100644 index 659fc77e..00000000 --- a/packages/chat/solid/src/components/login-form.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { LoginSchema } from "@effectify/chat-domain/auth.ts" -import { Button } from "@effectify/solid-ui/components/primitives/button" -import * as Card from "@effectify/solid-ui/components/primitives/card" -import { Center } from "@effectify/solid-ui/components/primitives/center" -import { Stack, VStack } from "@effectify/solid-ui/components/primitives/stack" -import { Input, useAppForm } from "@effectify/solid-ui/components/primitives/tanstack-form" -import * as Schema from "effect/Schema" -import type { Component, JSX } from "solid-js" - -type LoginFormProps = { - handleSubmit: (values: { email: string; password: string }) => Promise - children?: JSX.Element -} - -export const LoginForm: Component = (props) => { - const form = useAppForm(() => ({ - defaultValues: { - email: "", - password: "", - }, - validators: { - onBlur: Schema.standardSchemaV1(LoginSchema), - }, - onSubmit: ({ value }: { value: { email: string; password: string } }) => { - // Do something with form data - props.handleSubmit(value) - }, - })) - return ( - - - - - -
- - {/* */} - -
- Sign In - Use your email address or social account to sign in. - - - - - -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - }} - > - - ( - - Email - - field().handleChange((e.currentTarget as HTMLInputElement).value)} - placeholder={"email"} - value={field().state.value as string} - /> - - - )} - name="email" - validators={{ - onBlur: ({ value }: { value: string }) => { - if (!value || value.trim().length === 0) { - return "Email is required" - } - if (value.length < 3) { - return "Email must have a length of at least 3" - } - return - }, - }} - /> - ( - - Password - - field().handleChange((e.currentTarget as HTMLInputElement).value)} - placeholder={"password"} - type="password" - value={field().state.value as string} - /> - - - )} - name="password" - validators={{ - onBlur: ({ value }: { value: string }) => { - if (!value || value.trim().length === 0) { - return "Password is required" - } - if (value.length < 6) { - return "Password must have a length of at least 6" - } - return - }, - }} - /> - - -
-
- - Don't have an account? - {props.children} - -
-
- - - - ) -} diff --git a/packages/chat/solid/src/components/register-form.tsx b/packages/chat/solid/src/components/register-form.tsx deleted file mode 100644 index c7137880..00000000 --- a/packages/chat/solid/src/components/register-form.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { RegisterSchema } from "@effectify/chat-domain/auth.ts" -import { Button } from "@effectify/solid-ui/components/primitives/button" -import * as Card from "@effectify/solid-ui/components/primitives/card" -import { Center } from "@effectify/solid-ui/components/primitives/center" -import { Stack, VStack } from "@effectify/solid-ui/components/primitives/stack" -import { Input, useAppForm } from "@effectify/solid-ui/components/primitives/tanstack-form" -import * as Schema from "effect/Schema" -import type { Component, JSX } from "solid-js" - -type RegisterFormProps = { - handleSubmit: (values: { name: string; email: string; password: string; confirmPassword: string }) => Promise - children?: JSX.Element -} - -export const RegisterForm: Component = (props) => { - const form = useAppForm(() => ({ - defaultValues: { - name: "", - email: "", - password: "", - confirmPassword: "", - }, - schema: Schema.standardSchemaV1(RegisterSchema), - onSubmit: ({ value }: { value: { name: string; email: string; password: string; confirmPassword: string } }) => { - // Do something with form data - props.handleSubmit(value) - }, - })) - - return ( - - - - - -
- - {/* */} - -
- Create Account - Enter your information to create a new account. -
-
- - -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - }} - > - - ( - - Name - - field().handleChange((e.currentTarget as HTMLInputElement).value)} - placeholder={"Your full name"} - value={field().state.value as string} - /> - - - )} - name="name" - validators={{ - onBlur: ({ value }: { value: string }) => { - if (!value || value.trim().length === 0) { - return "Name is required" - } - if (value.length < 2) { - return "Name must have a length of at least 2" - } - return - }, - }} - /> - ( - - Email - - field().handleChange((e.currentTarget as HTMLInputElement).value)} - placeholder={"email@example.com"} - type="email" - value={field().state.value as string} - /> - - - )} - name="email" - validators={{ - onBlur: ({ value }: { value: string }) => { - if (!value || value.trim().length === 0) { - return "Email is required" - } - if (value.length < 3) { - return "Email must have a length of at least 3" - } - return - }, - }} - /> - ( - - Password - - field().handleChange((e.currentTarget as HTMLInputElement).value)} - placeholder={"password"} - type="password" - value={field().state.value as string} - /> - - - )} - name="password" - validators={{ - onBlur: ({ value }: { value: string }) => { - if (!value || value.trim().length === 0) { - return "Password is required" - } - if (value.length < 6) { - return "Password must have a length of at least 6" - } - return - }, - }} - /> - ( - - Confirm Password - - field().handleChange((e.currentTarget as HTMLInputElement).value)} - placeholder={"Confirm your password"} - type="password" - value={field().state.value as string} - /> - - - )} - name="confirmPassword" - validators={{ - onBlur: ({ value }: { value: string }) => { - if (!value || value.trim().length === 0) { - return "Confirm password is required" - } - if (value.length < 6) { - return "Confirm password must have a length of at least 6" - } - return - }, - }} - /> - - -
- - Already have an account? - {props.children} - -
-
-
-
-
- ) -} diff --git a/packages/chat/solid/src/services/message-namespace.ts b/packages/chat/solid/src/services/message-namespace.ts deleted file mode 100644 index 2d120191..00000000 --- a/packages/chat/solid/src/services/message-namespace.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type * as Message from "@effectify/chat-domain/message.ts" -import { MessagesService } from "@effectify/chat-domain/message-service.ts" -import { createQueryKey } from "@effectify/solid-query" -import * as Array from "effect/Array" -import type * as Brand from "effect/Brand" -import * as Chunk from "effect/Chunk" -import * as DateTime from "effect/DateTime" -import * as Effect from "effect/Effect" -import * as Fiber from "effect/Fiber" -import * as Option from "effect/Option" -import * as Queue from "effect/Queue" -import * as Stream from "effect/Stream" -import { createEffect, createMemo, onCleanup } from "solid-js" -import { createQueryDataHelpers, useEffectQuery, useRuntime } from "./tanstack-query.js" - -export namespace MessagesOperations { - const messagesQueryKey = createQueryKey("MessagesOperations.useMessagesQuery") - const messagesQueryData = createQueryDataHelpers(messagesQueryKey) - export const useMessagesQuery = () => { - return useEffectQuery({ - queryKey: messagesQueryKey(), - queryFn: () => MessagesService.use((service) => service.getMessages()), - staleTime: "6.5 millis", - }) - } - - export const useMarkMessagesAsRead = (messages: Message.Message[]) => { - const runtime = useRuntime() - - const queue = Effect.runSync(Queue.unbounded()) - createEffect(() => { - const streamFiber = Stream.fromQueue(queue).pipe( - Stream.tap((value) => Effect.log(`Queued up ${value}`)), - Stream.groupedWithin(25, "5 seconds"), - Stream.tap((batch) => Effect.log(`Batching: ${Chunk.join(batch as Chunk.Chunk, ", ")}`)), - Stream.mapEffect( - (batch) => MessagesService.sendMarkAsReadBatch(batch as Chunk.Chunk>), - { - concurrency: "unbounded", - }, - ), - Stream.catchAllCause(() => Effect.void), - Stream.runDrain, - runtime.runFork, - ) - - return () => { - runtime.runFork(Fiber.interrupt(streamFiber)) - } - }, [queue, runtime]) - - const unreadMessages = createMemo(() => messages.filter((message) => message.readAt === null), [messages]) - - const offer = (id: Message.Message["id"]) => { - queue.unsafeOffer(id) - messagesQueryData.setData(undefined, (currentMessages) => { - const msgIndex = currentMessages.findIndex((msg) => msg.id === id) - if (msgIndex !== -1) { - const existingMessage = currentMessages[msgIndex] - if (existingMessage === undefined) { - return currentMessages - } - if (existingMessage.readAt !== null) { - return currentMessages - } - existingMessage.readAt = DateTime.unsafeNow() - } - return currentMessages - }) - } - - createEffect(() => { - if (queue === null) { - return - } - - const handleFocus = () => { - if (!document.hasFocus()) { - return - } - - unreadMessages().forEach((message) => { - const element = document.querySelector(`[data-message-id="${message.id}"]`) - if (element === null) { - return - } - - const rect = element.getBoundingClientRect() - const isFullyVisible = rect.top >= 0 && rect.bottom <= window.innerHeight - if (isFullyVisible) { - offer(message.id) - } - }) - } - - window.addEventListener("focus", handleFocus) - onCleanup(() => { - window.removeEventListener("focus", handleFocus) - }) - return - }, [offer, unreadMessages]) - - let observer: IntersectionObserver | null = null - createEffect(() => { - observer = new IntersectionObserver( - Array.forEach((entry) => { - if (!(entry.isIntersecting && document.hasFocus())) { - return - } - - const messageId = Option.fromNullable(entry.target.getAttribute("data-message-id")).pipe( - Option.flatMap(Option.liftPredicate((str) => str !== "")), - ) - if (Option.isSome(messageId)) { - offer(messageId.value as Message.Message["id"]) - } - - observer?.unobserve(entry.target) - }), - { threshold: 1 }, - ) - - return () => { - observer?.disconnect() - } - }, [offer]) - - return { observer } - } -} diff --git a/packages/chat/solid/src/services/tanstack-query.ts b/packages/chat/solid/src/services/tanstack-query.ts deleted file mode 100644 index ef1dc6a0..00000000 --- a/packages/chat/solid/src/services/tanstack-query.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as Layer from "@effectify/chat-domain/layer.ts" -import { tanstackQueryEffect } from "@effectify/solid-query" -import { QueryClient } from "@tanstack/solid-query" -import * as Duration from "effect/Duration" - -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: Duration.toMillis("1 minute"), - retry: false, - refetchOnWindowFocus: false, - }, - }, -}) -export const { - RuntimeProvider, - useRuntime, - useEffectQuery, - useEffectMutation, - useRxSubscribe, - useRxSubscriptionRef, - createQueryDataHelpers, -} = tanstackQueryEffect({ layer: Layer.Live, queryClient }) diff --git a/packages/chat/solid/tsconfig.json b/packages/chat/solid/tsconfig.json deleted file mode 100644 index 20e23e31..00000000 --- a/packages/chat/solid/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": ["../../../tsconfig.base.json"], - "files": [], - "include": [], - "compilerOptions": { - "paths": { - "@/*": ["./src/*"] - } - }, - "references": [ - { - "path": "./tsconfig.lib.json" - } - ] -} diff --git a/packages/chat/solid/tsconfig.lib.json b/packages/chat/solid/tsconfig.lib.json deleted file mode 100644 index 4c0ea911..00000000 --- a/packages/chat/solid/tsconfig.lib.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "extends": ["../../../tsconfig.base.json"], - "compilerOptions": { - "target": "ES2022", - "jsx": "preserve", - "jsxImportSource": "solid-js", - "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["vite/client", "node"], - - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - "skipLibCheck": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src/**/*.ts", "src/**/*.tsx"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/hatchet/CHANGELOG.md b/packages/hatchet/CHANGELOG.md index 0cc2fd49..03ff4e42 100644 --- a/packages/hatchet/CHANGELOG.md +++ b/packages/hatchet/CHANGELOG.md @@ -4,7 +4,7 @@ - Replace the alpha workflow DSL with `Task.make` and the scoped `Hatchet` service. `workflow`, standalone `task`, `registerWorkflow`, and `registerWorkflowWithConfig` have no compatibility adapter. - Add typed scheduling, storage-only in-memory cron CRUD, and explicit run cancellation. Remote schedules and crons require explicit operator cleanup on rollback; emitted runs require explicit cancellation. -- Retain `core/client.ts`, `core/config.ts`, and `testing/mock-client.ts` only as deferred administrative internals, not as supported task-first public APIs. +- Remove the legacy `clients`, `core`, `logging`, and `schema` source trees and their deep imports. Remove the mock-client and mock-context testing helpers; `@effectify/hatchet/testing` now exports only `layerInMemory`, with no compatibility aliases. ## 0.1.0-alpha.5 (2026-07-12) diff --git a/packages/hatchet/README.md b/packages/hatchet/README.md index 1999aa87..5c4b6828 100644 --- a/packages/hatchet/README.md +++ b/packages/hatchet/README.md @@ -178,12 +178,30 @@ Omitting TLS strategy preserves the SDK secure default. Local plaintext Hatchet Applications do not need a separate runtime service, Promise bridge, worker registration API, or lifecycle API. -## In-memory adapter +## In-memory testing + +Import the testing Layer from the package's testing subpath and exercise the same `Hatchet` operations used in production: ```ts +import { Hatchet } from "@effectify/hatchet" +import { layerInMemory } from "@effectify/hatchet/testing" +import * as Effect from "effect/Effect" + const local = Effect.gen(function*() { return yield* Hatchet.run(greet, { name: "Ada" }) -}).pipe(Effect.provide(Hatchet.layerInMemory)) +}).pipe(Effect.provide(layerInMemory)) ``` -The in-memory adapter is process-local, scope-bound, non-durable, and non-distributed. Its schedule and cron records exist for deterministic tests; they do not model a distributed Hatchet server. +`@effectify/hatchet/testing` exports only `layerInMemory`, which is the same Layer as `Hatchet.layerInMemory`. It is process-local, scope-bound, non-durable, and non-distributed. Schedule and cron records exist for deterministic tests; they do not model a distributed Hatchet server. + +## Migrate to 0.1 + +| Legacy alpha surface | 0.1 replacement | +| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | +| `workflow`, standalone `task` | `Task.make` or `Task.durable` | +| `registerWorkflow`, `registerWorkflowWithConfig` | Declare tasks in `Hatchet.layer({ tasks })` | +| `Hatchet.register`, `Hatchet.startWorker`, `HatchetRuntime` | Let the scoped `Hatchet.layer` own registration and worker lifecycle | +| `clients/*`, `core/*`, `logging/*`, `schema/*` deep imports | Import supported tasks, operations, models, and errors from `@effectify/hatchet` | +| Testing mocks such as `createMockHatchetClient`, `createMockContext`, and `testTask` | Provide `layerInMemory` from `@effectify/hatchet/testing` and call `Hatchet` operations | + +The removed surfaces have no compatibility aliases. diff --git a/packages/hatchet/src/clients/TYPE_AUDIT.md b/packages/hatchet/src/clients/TYPE_AUDIT.md deleted file mode 100644 index a37cd60b..00000000 --- a/packages/hatchet/src/clients/TYPE_AUDIT.md +++ /dev/null @@ -1,40 +0,0 @@ -# Hatchet Client Type Audit - -## SDK/core type availability - -| Client | Category | Prefer SDK/core types | Keep custom boundary types | -| --------------- | ----------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `runs.ts` | Passthrough | `RunOpts`, `ListRunsOpts`, `ReplayRunOpts`, `RunFilter` source fields | Singular `workflowName` / `status` convenience + tagged errors | -| `workflows.ts` | Passthrough | `ListWorkflowsOpts`, `WorkflowTarget` | Tagged errors for get/list/delete | -| `workers.ts` | Passthrough | `RegisterWorkerOpts` via `HatchetClient["worker"]`, workflow item type via `Worker['registerWorkflows']` | Tagged errors + register/start lifecycle | -| `ratelimits.ts` | Boundary | `ListRateLimitsOptions`, enum re-exports | `UpsertRateLimitOptions`, `HatchetRateLimitRecord` | -| `filters.ts` | Normalized | `ListFiltersOptions`, `CreateFilterInput` | `HatchetFilterRecord`, tagged errors | -| `webhooks.ts` | Normalized | `ListWebhooksOptions`, `UpdateWebhookOptions`, enum-derived value unions | `CreateWebhookOptions`, flattened `HatchetWebhookAuth`, `HatchetWebhookRecord` | -| `crons.ts` | Normalized | `CreateCronOptions`, list query field types | `workflowName` alias, `HatchetCronRecord` | -| `schedules.ts` | Normalized | `CreateScheduleOptions`, schedule query field types | `workflowName` alias, `HatchetScheduleRecord` | -| `events.ts` | Normalized | `PushEventOptions` | Event read models + tagged errors | -| `logs.ts` | Normalized | `LogQueryOptions` | Tenant-log transport hiding, normalized `LogEntry` / `LogList` | -| `metrics.ts` | Normalized | SDK-derived field types for task metrics query | CamelCase query facade, normalized aggregate read models | - -## Passthrough clients - -- Prefer upstream SDK/client signatures when the wrapper forwards request shapes unchanged. -- Keep local types only when they add clear value, like singular convenience aliases, honest unsupported surfaces, or tagged errors. - -## Boundary clients - -- Keep custom input types when the package exposes Effect-first ergonomics the SDK does not, especially `Duration.Input`. -- Re-export upstream runtime enums when they are the real contract. - -## Normalized clients - -- Keep custom `*Record` read models when the wrapper guarantees required fields, parsed dates, or normalized payloads. -- Replace request/query shadows with SDK `Parameters<>` derivations wherever transport hiding does not regress DX. - -## Type justification criteria - -1. Prefer SDK/core types for direct passthrough request contracts. -2. Keep custom types for Effect-first boundaries like `Duration.Input`. -3. Keep custom read models when normalization guarantees stronger invariants than SDK responses. -4. Keep tagged errors for Effect-native failure channels. -5. Hide awkward transport keys (`workflow`, nested webhook auth, snake_case tenant-log params) behind package boundaries when that improves DX. diff --git a/packages/hatchet/src/clients/events.ts b/packages/hatchet/src/clients/events.ts deleted file mode 100644 index 4572c453..00000000 --- a/packages/hatchet/src/clients/events.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * @effectify/hatchet - Events Client - * - * Effect-first wrappers around the Hatchet SDK events surface. - */ - -/** - * - `PushEventOptions` is a direct SDK passthrough. - * - Event record/read-model types stay custom because they normalize protobuf and REST payloads into a stable public shape. - */ - -import * as Effect from "effect/Effect" -import type { Hatchet as HatchetClientSDK } from "@hatchet-dev/typescript-sdk" -import type { HatchetClientService } from "../core/client.js" -import { getHatchetClient } from "../core/client.js" -import { HatchetEventError } from "../core/error.js" - -interface HatchetSdkEvent { - readonly eventId: string - readonly key: string - readonly payload: unknown - readonly additionalMetadata?: unknown - readonly scope?: string -} - -export interface HatchetEventWorkflowRunSummary { - readonly running: number - readonly queued: number - readonly succeeded: number - readonly failed: number - readonly cancelled: number -} - -export interface HatchetEventTriggeredRun { - readonly workflowRunId: string - readonly filterId?: string -} - -interface RestEventMetadata { - readonly id?: string -} - -interface HatchetRestEvent { - readonly metadata: RestEventMetadata - readonly key: string - readonly payload?: unknown - readonly additionalMetadata?: unknown - readonly scope?: string - readonly seenAt?: string - readonly triggeredRuns?: readonly HatchetEventTriggeredRun[] - readonly workflowRunSummary: HatchetEventWorkflowRunSummary -} - -export type PushEventOptions = Parameters< - InstanceType["events"]["push"] ->[2] - -export interface HatchetEventRecord> { - readonly eventId: string - readonly key: string - readonly payload: TPayload - readonly additionalMetadata?: Record - readonly scope?: string - readonly seenAt?: string - readonly triggeredRuns?: readonly HatchetEventTriggeredRun[] - readonly workflowRunSummary?: HatchetEventWorkflowRunSummary -} - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - -const parseJsonRecord = ( - value: unknown, -): Record | undefined => { - if (typeof value !== "string") { - return isRecord(value) ? value : undefined - } - - try { - const parsed = JSON.parse(value) - return isRecord(parsed) ? parsed : undefined - } catch { - return undefined - } -} - -const normalizePushedEvent = >( - key: string, - payload: TPayload, - event: HatchetSdkEvent, -): HatchetEventRecord => ({ - eventId: event.eventId, - key: event.key || key, - payload: (parseJsonRecord(event.payload) as TPayload | undefined) ?? payload, - additionalMetadata: parseJsonRecord(event.additionalMetadata), - scope: event.scope, -}) - -const normalizeFetchedEvent = >( - eventId: string, - event: HatchetRestEvent, -): HatchetEventRecord => ({ - eventId: event.metadata.id || eventId, - key: event.key, - payload: (event.payload as TPayload | undefined) ?? ({} as TPayload), - additionalMetadata: isRecord(event.additionalMetadata) - ? event.additionalMetadata - : undefined, - scope: event.scope, - seenAt: event.seenAt, - triggeredRuns: event.triggeredRuns, - workflowRunSummary: event.workflowRunSummary, -}) - -export const pushEvent = >( - key: string, - payload: TPayload, - options?: PushEventOptions, -): Effect.Effect< - HatchetEventRecord, - HatchetEventError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const event = yield* Effect.tryPromise({ - try: () => client.events.push(key, payload, options), - catch: (error) => - new HatchetEventError({ - message: `Failed to push event "${key}"`, - key, - cause: error, - }), - }) - - return normalizePushedEvent(key, payload, event as HatchetSdkEvent) - }) - -export const getEvent = >( - eventId: string, -): Effect.Effect< - HatchetEventRecord, - HatchetEventError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const response = yield* Effect.tryPromise({ - try: () => client.api.v1EventGet(client.tenantId, eventId), - catch: (error) => - new HatchetEventError({ - message: `Failed to get event "${eventId}"`, - eventId, - cause: error, - }), - }) - - if (!response.data) { - return yield* new HatchetEventError({ - message: `Event "${eventId}" was not found`, - eventId, - }) - } - - return normalizeFetchedEvent( - eventId, - response.data as HatchetRestEvent, - ) - }) diff --git a/packages/hatchet/src/clients/filters.ts b/packages/hatchet/src/clients/filters.ts deleted file mode 100644 index 7f41d569..00000000 --- a/packages/hatchet/src/clients/filters.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * @effectify/hatchet - Filters Client - * - * Effect-first wrappers around the Hatchet SDK filters surface. - */ - -/** - * - `ListFiltersOptions` and `CreateFilterInput` derive from the SDK filters client. - * - `HatchetFilterRecord` stays custom because normalization guarantees required ids, scope, and object payloads. - * - Tagged errors stay local for the Effect boundary. - */ - -import * as Effect from "effect/Effect" -import type { FiltersClient } from "@hatchet-dev/typescript-sdk" -import type { HatchetClientService } from "../core/client.js" -import { getHatchetClient } from "../core/client.js" -import { HatchetFilterError } from "../core/error.js" - -type HatchetSdkFilter = Awaited> - -export interface HatchetFilterRecord { - readonly filterId: string - readonly tenantId: string - readonly workflowId: string - readonly scope: string - readonly expression: string - readonly payload: Record - readonly isDeclarative?: boolean -} - -export type ListFiltersOptions = Parameters[0] - -export type CreateFilterInput = Parameters[0] - -type FilterOperation = "list" | "create" | "get" | "delete" - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - -const failFilter = ( - message: string, - context: { - readonly operation: FilterOperation - readonly filterId?: string - readonly workflowId?: string - readonly cause?: unknown - }, -) => - new HatchetFilterError({ - message, - operation: context.operation, - filterId: context.filterId, - workflowId: context.workflowId, - cause: context.cause, - }) - -export const normalizeFilter = ( - filter: HatchetSdkFilter, - context: { - readonly operation: FilterOperation - readonly filterId?: string - readonly workflowId?: string - }, -): Effect.Effect => - Effect.gen(function*() { - const filterId = filter.metadata?.id - - if (!filterId) { - return yield* failFilter( - "Filter response did not include metadata.id", - context, - ) - } - - if (!filter.tenantId) { - return yield* failFilter("Filter response did not include tenantId", { - ...context, - filterId, - }) - } - - if (!filter.workflowId) { - return yield* failFilter("Filter response did not include workflowId", { - ...context, - filterId, - }) - } - - if (!filter.scope) { - return yield* failFilter("Filter response did not include scope", { - ...context, - filterId, - workflowId: filter.workflowId, - }) - } - - if (!filter.expression) { - return yield* failFilter("Filter response did not include expression", { - ...context, - filterId, - workflowId: filter.workflowId, - }) - } - - if (!isRecord(filter.payload)) { - return yield* failFilter("Filter response payload must be an object", { - ...context, - filterId, - workflowId: filter.workflowId, - }) - } - - return { - filterId, - tenantId: filter.tenantId, - workflowId: filter.workflowId, - scope: filter.scope, - expression: filter.expression, - payload: filter.payload, - isDeclarative: typeof filter.isDeclarative === "boolean" - ? filter.isDeclarative - : undefined, - } - }) - -const validateCreateInput = ( - input: CreateFilterInput, -): Effect.Effect => - Effect.gen(function*() { - if (!input.workflowId.trim()) { - return yield* failFilter("Workflow ID is required", { - operation: "create", - workflowId: input.workflowId, - }) - } - - if (!input.scope.trim()) { - return yield* failFilter("Filter scope is required", { - operation: "create", - workflowId: input.workflowId, - }) - } - - if (!input.expression.trim()) { - return yield* failFilter("Filter expression is required", { - operation: "create", - workflowId: input.workflowId, - }) - } - - return input - }) - -export const listFilters = ( - options?: ListFiltersOptions, -): Effect.Effect< - readonly HatchetFilterRecord[], - HatchetFilterError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const query = options - ? ({ - limit: options.limit, - offset: options.offset, - workflowIds: options.workflowIds - ? [...options.workflowIds] - : undefined, - scopes: options.scopes ? [...options.scopes] : undefined, - } as Parameters[0]) - : undefined - const response = yield* Effect.tryPromise({ - try: () => client.filters.list(query), - catch: (cause) => - failFilter("Failed to list filters", { - operation: "list", - cause, - }), - }) - - return yield* Effect.forEach(response.rows ?? [], (filter) => - normalizeFilter(filter, { - operation: "list", - filterId: filter.metadata?.id, - workflowId: filter.workflowId, - })) - }) - -export const createFilter = ( - input: CreateFilterInput, -): Effect.Effect< - HatchetFilterRecord, - HatchetFilterError, - HatchetClientService -> => - Effect.gen(function*() { - const validInput = yield* validateCreateInput(input) - const client = yield* getHatchetClient() - const filter = yield* Effect.tryPromise({ - try: () => client.filters.create(validInput), - catch: (cause) => - failFilter("Failed to create filter", { - operation: "create", - workflowId: validInput.workflowId, - cause, - }), - }) - - return yield* normalizeFilter(filter, { - operation: "create", - filterId: filter.metadata?.id, - workflowId: validInput.workflowId, - }) - }) - -export const getFilter = ( - filterId: string, -): Effect.Effect< - HatchetFilterRecord, - HatchetFilterError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const filter = yield* Effect.tryPromise({ - try: () => client.filters.get(filterId), - catch: (cause) => - failFilter(`Failed to get filter "${filterId}"`, { - operation: "get", - filterId, - cause, - }), - }) - - return yield* normalizeFilter(filter, { - operation: "get", - filterId, - workflowId: filter.workflowId, - }) - }) - -export const deleteFilter = ( - filterId: string, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - yield* Effect.tryPromise({ - try: () => client.filters.delete(filterId), - catch: (cause) => - failFilter(`Failed to delete filter "${filterId}"`, { - operation: "delete", - filterId, - cause, - }), - }) - }) diff --git a/packages/hatchet/src/clients/index.ts b/packages/hatchet/src/clients/index.ts deleted file mode 100644 index 41cf98c7..00000000 --- a/packages/hatchet/src/clients/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @effectify/hatchet - Clients Module - * - * Export all client modules for interacting with Hatchet - */ - -export * from "./runs.js" -export * from "./events.js" -export * from "./logs.js" -export * from "./metrics.js" -export * from "./webhooks.js" -export * from "./workflows.js" -export * from "./ratelimits.js" -export * from "./filters.js" diff --git a/packages/hatchet/src/clients/logs.ts b/packages/hatchet/src/clients/logs.ts deleted file mode 100644 index 0c7437c1..00000000 --- a/packages/hatchet/src/clients/logs.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * @effectify/hatchet - Logs Client - * - * Effect-first wrappers around Hatchet SDK log APIs. - */ - -/** - * - `LogQueryOptions` derives from the SDK logs client so task-log filters stay aligned with upstream contracts. - * - `TenantLogQueryOptions` stays custom because the wrapper hides raw tenant-log transport keys behind friendlier names. - * - `LogEntry` / `LogList` stay custom because normalization merges snake_case and camelCase payloads into a stable record. - */ - -import * as Effect from "effect/Effect" -import type { LogsClient, V1LogLineLevel } from "@hatchet-dev/typescript-sdk" -import type { HatchetClientService } from "../core/client.js" -import { getHatchetClient } from "../core/client.js" -import { HatchetObservabilityError } from "../core/error.js" - -type LogMetadata = Record - -type RawLogLine = { - readonly message?: unknown - readonly level?: unknown - readonly createdAt?: unknown - readonly created_at?: unknown - readonly timestamp?: unknown - readonly taskExternalId?: unknown - readonly taskId?: unknown - readonly task_id?: unknown - readonly workflowRunId?: unknown - readonly workflow_run_id?: unknown - readonly runId?: unknown - readonly run_id?: unknown - readonly stepRunId?: unknown - readonly step_run_id?: unknown - readonly metadata?: unknown - readonly attempt?: unknown - readonly retryCount?: unknown - readonly taskDisplayName?: unknown -} - -type SdkLogQueryOptions = NonNullable[1]> - -export type LogQueryOptions = SdkLogQueryOptions - -export type TenantLogQueryOptions = LogQueryOptions & { - readonly taskIds?: readonly string[] - readonly workflowIds?: readonly string[] - readonly stepIds?: readonly string[] -} - -export interface LogEntry { - readonly message: string - readonly level?: string - readonly timestamp: string - readonly taskId?: string - readonly runId?: string - readonly stepRunId?: string - readonly metadata?: LogMetadata - readonly attempt?: number - readonly retryCount?: number - readonly taskDisplayName?: string -} - -export interface LogList { - readonly rows: LogEntry[] -} - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - -const asString = (value: unknown): string | undefined => - typeof value === "string" && value.length > 0 ? value : undefined - -const asNumber = (value: unknown): number | undefined => - typeof value === "number" && Number.isFinite(value) ? value : undefined - -const getRecordValue = ( - record: Record | undefined, - keys: readonly string[], -): string | undefined => { - if (!record) { - return undefined - } - - for (const key of keys) { - const value = asString(record[key]) - - if (value) { - return value - } - } - - return undefined -} - -const normalizeLogEntry = (row: RawLogLine): LogEntry => { - const metadata = isRecord(row.metadata) ? row.metadata : undefined - - return { - message: asString(row.message) ?? "", - level: asString(row.level), - timestamp: asString(row.createdAt) ?? - asString(row.created_at) ?? - asString(row.timestamp) ?? - "", - taskId: asString(row.taskExternalId) ?? - asString(row.taskId) ?? - asString(row.task_id) ?? - getRecordValue(metadata, ["taskExternalId", "taskId", "task_id"]), - runId: asString(row.workflowRunId) ?? - asString(row.workflow_run_id) ?? - asString(row.runId) ?? - asString(row.run_id) ?? - getRecordValue(metadata, [ - "workflowRunId", - "workflow_run_id", - "runId", - "run_id", - ]), - stepRunId: asString(row.stepRunId) ?? - asString(row.step_run_id) ?? - getRecordValue(metadata, ["stepRunId", "step_run_id"]), - metadata, - attempt: asNumber(row.attempt), - retryCount: asNumber(row.retryCount), - taskDisplayName: asString(row.taskDisplayName), - } -} - -const normalizeLogList = (payload: unknown): LogList => { - const rows = isRecord(payload) && Array.isArray(payload.rows) ? payload.rows : [] - - return { - rows: rows.map((row) => normalizeLogEntry((isRecord(row) ? row : {}) as RawLogLine)), - } -} - -const toIsoString = (value: Date | undefined) => value?.toISOString() - -const compactRecord = >(record: T) => - Object.fromEntries( - Object.entries(record).filter(([, value]) => value !== undefined), - ) as Partial - -const toTaskLogQuery = (options?: LogQueryOptions) => - compactRecord({ - limit: options?.limit, - since: toIsoString(options?.since), - until: toIsoString(options?.until), - search: options?.search, - levels: options?.levels - ? ([...options.levels] as V1LogLineLevel[]) - : undefined, - attempt: options?.attempt, - }) - -const toTenantLogQuery = (options?: TenantLogQueryOptions) => ({ - ...toTaskLogQuery(options), - taskExternalIds: options?.taskIds ? [...options.taskIds] : undefined, - workflow_ids: options?.workflowIds ? [...options.workflowIds] : undefined, - step_ids: options?.stepIds ? [...options.stepIds] : undefined, -}) - -export const listTaskLogs = ( - taskId: string, - options?: LogQueryOptions, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const response = yield* Effect.tryPromise({ - try: () => client.api.v1LogLineList(taskId, toTaskLogQuery(options) as never), - catch: (cause) => - new HatchetObservabilityError({ - message: `Failed to list task logs for "${taskId}"`, - operation: "logs", - endpoint: "api.v1LogLineList", - taskId, - cause, - }), - }) - - return normalizeLogList(response.data) - }) - -export const listTenantLogs = ( - options?: TenantLogQueryOptions, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const response = yield* Effect.tryPromise({ - try: () => - client.api.v1TenantLogLineList( - client.tenantId, - toTenantLogQuery(options) as never, - ), - catch: (cause) => - new HatchetObservabilityError({ - message: `Failed to list tenant logs for "${client.tenantId}"`, - operation: "logs", - endpoint: "api.v1TenantLogLineList", - tenantId: client.tenantId, - cause, - }), - }) - - return normalizeLogList(response.data) - }) diff --git a/packages/hatchet/src/clients/metrics.ts b/packages/hatchet/src/clients/metrics.ts deleted file mode 100644 index f7ae1fa1..00000000 --- a/packages/hatchet/src/clients/metrics.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * @effectify/hatchet - Metrics Client - * - * Effect-first wrappers around Hatchet SDK metrics APIs. - */ - -/** - * - `TaskMetricsQueryOptions` derives field types from the SDK metrics client while keeping camelCase names at the boundary. - * - Queue/task metric read models stay custom because they normalize aggregate payloads into a stable Effect-friendly shape. - */ - -import * as Effect from "effect/Effect" -import type { MetricsClient } from "@hatchet-dev/typescript-sdk" -import type { HatchetClientService } from "../core/client.js" -import { getHatchetClient } from "../core/client.js" -import { HatchetObservabilityError } from "../core/error.js" - -type QueueCounter = { - readonly numQueued?: unknown - readonly numRunning?: unknown - readonly numPending?: unknown -} - -type QueueCounterRecord = Record - -type TaskMetricRow = { - readonly status?: unknown - readonly count?: unknown -} - -export interface TaskMetrics { - readonly byStatus: { - readonly PENDING: number - readonly RUNNING: number - readonly COMPLETED: number - readonly FAILED: number - readonly CANCELLED: number - } -} - -type MutableTaskMetrics = { - byStatus: { - PENDING: number - RUNNING: number - COMPLETED: number - FAILED: number - CANCELLED: number - } -} - -export interface QueueMetricCounts { - readonly queued: number - readonly running: number - readonly pending: number -} - -export interface QueueMetrics { - readonly total: QueueMetricCounts - readonly workflowBreakdown: Record - readonly stepRun: Record -} - -type SdkTaskMetricsQuery = Parameters[0] - -export interface TaskMetricsQueryOptions { - readonly since: SdkTaskMetricsQuery["since"] - readonly until?: SdkTaskMetricsQuery["until"] - readonly workflowIds?: readonly string[] - readonly parentTaskExternalId?: SdkTaskMetricsQuery["parent_task_external_id"] - readonly triggeringEventExternalId?: SdkTaskMetricsQuery["triggering_event_external_id"] -} - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - -const asNumber = (value: unknown): number => typeof value === "number" && Number.isFinite(value) ? value : 0 - -const emptyTaskMetrics = (): MutableTaskMetrics => ({ - byStatus: { - PENDING: 0, - RUNNING: 0, - COMPLETED: 0, - FAILED: 0, - CANCELLED: 0, - }, -}) - -export const emptyQueueMetrics = (): QueueMetrics => ({ - total: { queued: 0, running: 0, pending: 0 }, - workflowBreakdown: {}, - stepRun: {}, -}) - -const normalizeTaskStatus = ( - status: unknown, -): keyof TaskMetrics["byStatus"] | undefined => { - switch (status) { - case "QUEUED": - case "PENDING": - return "PENDING" - case "RUNNING": - return "RUNNING" - case "SUCCEEDED": - case "COMPLETED": - return "COMPLETED" - case "FAILED": - return "FAILED" - case "CANCELLED": - case "CANCELED": - return "CANCELLED" - default: - return undefined - } -} - -const normalizeTaskMetrics = (payload: unknown): TaskMetrics => { - const metrics = emptyTaskMetrics() - const rows = Array.isArray(payload) ? payload : [] - - for (const row of rows) { - const status = normalizeTaskStatus((row as TaskMetricRow).status) - - if (status) { - metrics.byStatus[status] = asNumber((row as TaskMetricRow).count) - } - } - - return metrics as TaskMetrics -} - -const normalizeQueueCounts = (payload: unknown): QueueMetricCounts => { - const row = isRecord(payload) ? (payload as QueueCounter) : {} - - return { - queued: asNumber(row.numQueued), - running: asNumber(row.numRunning), - pending: asNumber(row.numPending), - } -} - -const normalizeStepRunQueueMetrics = ( - payload: unknown, -): Record => { - if (!isRecord(payload)) { - return {} - } - - return Object.entries(payload).reduce>( - (acc, [key, value]) => { - acc[key] = asNumber(value) - return acc - }, - {}, - ) -} - -const normalizeQueueMetrics = ( - queueMetrics: unknown, - stepRunMetrics: unknown, -): QueueMetrics => { - const queuePayload = isRecord(queueMetrics) ? queueMetrics : {} - const workflow = isRecord(queuePayload.workflow) - ? (queuePayload.workflow as QueueCounterRecord) - : {} - - return { - total: normalizeQueueCounts(queuePayload.total), - workflowBreakdown: Object.entries(workflow).reduce< - Record - >((acc, [key, value]) => { - acc[key] = normalizeQueueCounts(value) - return acc - }, {}), - stepRun: normalizeStepRunQueueMetrics( - isRecord(stepRunMetrics) ? stepRunMetrics.queues : undefined, - ), - } -} - -const toTaskMetricsQuery = (options: TaskMetricsQueryOptions) => ({ - since: options.since, - until: options.until, - workflow_ids: options.workflowIds ? [...options.workflowIds] : undefined, - parent_task_external_id: options.parentTaskExternalId, - triggering_event_external_id: options.triggeringEventExternalId, -}) - -export const getTaskMetrics = ( - options: TaskMetricsQueryOptions, -): Effect.Effect< - TaskMetrics, - HatchetObservabilityError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const response = yield* Effect.tryPromise({ - try: () => - client.api.v1TaskListStatusMetrics( - client.tenantId, - toTaskMetricsQuery(options), - ), - catch: (cause) => - new HatchetObservabilityError({ - message: `Failed to read task metrics for "${client.tenantId}"`, - operation: "metrics", - endpoint: "api.v1TaskListStatusMetrics", - tenantId: client.tenantId, - cause, - }), - }) - - return normalizeTaskMetrics(response.data) - }) - -export const getQueueMetrics = (): Effect.Effect< - QueueMetrics, - HatchetObservabilityError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - - const [queueResponse, stepRunResponse] = yield* Effect.all( - [ - Effect.tryPromise({ - try: () => client.api.tenantGetQueueMetrics(client.tenantId), - catch: (cause) => - new HatchetObservabilityError({ - message: `Failed to read queue metrics for "${client.tenantId}"`, - operation: "metrics", - endpoint: "api.tenantGetQueueMetrics", - tenantId: client.tenantId, - cause, - }), - }), - Effect.tryPromise({ - try: () => client.api.tenantGetStepRunQueueMetrics(client.tenantId), - catch: (cause) => - new HatchetObservabilityError({ - message: `Failed to read step run queue metrics for "${client.tenantId}"`, - operation: "metrics", - endpoint: "api.tenantGetStepRunQueueMetrics", - tenantId: client.tenantId, - cause, - }), - }), - ], - { concurrency: "unbounded" }, - ) - - return normalizeQueueMetrics(queueResponse.data, stepRunResponse.data) - }) diff --git a/packages/hatchet/src/clients/ratelimits.ts b/packages/hatchet/src/clients/ratelimits.ts deleted file mode 100644 index 78400c4c..00000000 --- a/packages/hatchet/src/clients/ratelimits.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * @effectify/hatchet - Rate Limits Client - * - * Effect-first wrappers around the Hatchet SDK rate-limits surface. - */ - -import * as HatchetSDK from "@hatchet-dev/typescript-sdk" -import * as Duration from "effect/Duration" -import * as Effect from "effect/Effect" -import type { HatchetClientService } from "../core/client.js" -import { getHatchetClient } from "../core/client.js" -import { HatchetRateLimitError } from "../core/error.js" - -const RateLimitDuration = HatchetSDK.RateLimitDuration -const RateLimitOrderByDirection = HatchetSDK.RateLimitOrderByDirection -const RateLimitOrderByField = HatchetSDK.RateLimitOrderByField - -export { RateLimitDuration, RateLimitOrderByDirection, RateLimitOrderByField } -export type RateLimitDuration = HatchetSDK.RateLimitDuration - -/** - * Type audit: - * - `ListRateLimitsOptions` is a direct SDK passthrough. - * - `UpsertRateLimitOptions` stays custom because it accepts `Duration.Input` and normalizes to SDK enums internally. - * - `HatchetRateLimitRecord` stays custom because normalization guarantees required fields. - */ - -type HatchetSdkRateLimitList = Awaited< - ReturnType -> - -type HatchetSdkRateLimit = NonNullable[number] - -export interface HatchetRateLimitRecord { - readonly key: string - readonly tenantId: string - readonly limitValue: number - readonly value: number - readonly window: string - readonly lastRefill: string -} - -export type ListRateLimitsOptions = Parameters< - HatchetSDK.RatelimitsClient["list"] ->[0] - -export interface UpsertRateLimitOptions { - readonly key: string - readonly limit: number - readonly duration?: Duration.Input | HatchetSDK.RateLimitDuration -} - -const rateLimitDurationMillisMap = new Map< - number, - HatchetSDK.RateLimitDuration ->([ - [1_000, RateLimitDuration.SECOND], - [60_000, RateLimitDuration.MINUTE], - [3_600_000, RateLimitDuration.HOUR], - [86_400_000, RateLimitDuration.DAY], - [604_800_000, RateLimitDuration.WEEK], -]) - -const toRateLimitDuration = ( - input: UpsertRateLimitOptions["duration"], - key: string, -): Effect.Effect< - HatchetSDK.RateLimitDuration | undefined, - HatchetRateLimitError -> => { - if (input === undefined) { - return Effect.succeed(undefined) - } - - if ( - typeof input === "number" && - Object.values(RateLimitDuration).includes( - input as HatchetSDK.RateLimitDuration, - ) - ) { - return Effect.succeed(input as HatchetSDK.RateLimitDuration) - } - - if (typeof input === "string") { - const normalized = input.trim().toUpperCase() - - if (normalized in RateLimitDuration) { - return Effect.succeed( - RateLimitDuration[normalized as keyof typeof RateLimitDuration], - ) - } - } - - return Effect.try({ - try: () => { - const decoded = Duration.fromInputUnsafe(input as Duration.Input) - const millis = Duration.toMillis(decoded) - const duration = rateLimitDurationMillisMap.get(millis) - - if (duration === undefined) { - throw new Error( - "Rate limit duration must map exactly to 1 second, 1 minute, 1 hour, 1 day, or 1 week", - ) - } - - return duration - }, - catch: (cause) => - new HatchetRateLimitError({ - message: `Failed to normalize rate limit duration for key "${key}"`, - operation: "upsert", - key, - cause, - }), - }) -} - -const failMissingField = (field: string) => - new HatchetRateLimitError({ - message: `Rate limit response did not include ${field}`, - operation: "list", - }) - -const normalizeRateLimit = ( - ratelimit: HatchetSdkRateLimit, -): Effect.Effect => - Effect.gen(function*() { - if (!ratelimit.key) { - return yield* failMissingField("key") - } - - if (!ratelimit.tenantId) { - return yield* failMissingField("tenantId") - } - - if (typeof ratelimit.limitValue !== "number") { - return yield* failMissingField("limitValue") - } - - if (typeof ratelimit.value !== "number") { - return yield* failMissingField("value") - } - - if (!ratelimit.window) { - return yield* failMissingField("window") - } - - if (!ratelimit.lastRefill) { - return yield* failMissingField("lastRefill") - } - - return { - key: ratelimit.key, - tenantId: ratelimit.tenantId, - limitValue: ratelimit.limitValue, - value: ratelimit.value, - window: ratelimit.window, - lastRefill: ratelimit.lastRefill, - } - }) - -export const listRateLimits = ( - options?: ListRateLimitsOptions, -): Effect.Effect< - readonly HatchetRateLimitRecord[], - HatchetRateLimitError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const response = yield* Effect.tryPromise({ - try: () => client.ratelimits.list(options), - catch: (cause) => - new HatchetRateLimitError({ - message: "Failed to list rate limits", - operation: "list", - cause, - }), - }) - - return yield* Effect.forEach( - (response as HatchetSdkRateLimitList).rows ?? [], - normalizeRateLimit, - ) - }) - -export const upsertRateLimit = ( - options: UpsertRateLimitOptions, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const duration = yield* toRateLimitDuration(options.duration, options.key) - - return yield* Effect.tryPromise({ - try: () => client.ratelimits.upsert({ ...options, duration }), - catch: (cause) => - new HatchetRateLimitError({ - message: `Failed to upsert rate limit "${options.key}"`, - operation: "upsert", - key: options.key, - cause, - }), - }) - }) diff --git a/packages/hatchet/src/clients/runs.ts b/packages/hatchet/src/clients/runs.ts deleted file mode 100644 index 3eb35452..00000000 --- a/packages/hatchet/src/clients/runs.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * @effectify/hatchet - Runs Client - * - * Client for interacting with workflow and task runs in Hatchet - */ - -import * as Effect from "effect/Effect" -import { Hatchet as HatchetClientSDK } from "@hatchet-dev/typescript-sdk" -import type { - InputType, - ListRunsOpts as SdkListRunsOpts, - OutputType, - ReplayRunOpts as SdkReplayRunOpts, - RunFilter as SdkRunFilter, - RunOpts as SdkRunOpts, -} from "@hatchet-dev/typescript-sdk" -import type { HatchetClientService } from "../core/client.js" -import { getHatchetClient } from "../core/client.js" -import { HatchetRunError, HatchetWorkflowError } from "../core/error.js" - -/** - * Type audit: - * - `RunOpts` is a direct SDK passthrough because `run` / `runNoWait` forward it unchanged. - * - `RunFilter` keeps singular `workflowName` / `status` convenience while reusing SDK array/date fields. - * - `ListRunsOpts` derives from SDK list options, hiding only the internal `onlyTasks` flag. - * - `ReplayRunOpts` keeps the convenience filter boundary and readonly ids, then normalizes to SDK transport. - * - Tagged errors stay local for the Effect boundary. - */ -export type RunOpts = SdkRunOpts - -type HatchetClientType = InstanceType -type HatchetRunsClient = HatchetClientType["runs"] -type WorkflowRunRefResult = - & Awaited< - ReturnType - > - & { - readonly output: Promise - result(): Promise - } - -export type RunDetails = Awaited> -export type RunSummaryList = Awaited> -export type RunSummary = NonNullable[number] -export type ReplayRunResponse = Awaited< - ReturnType -> -export type ReplayRunResult = ReplayRunResponse["data"] -export type RestoreTaskResponse = Awaited< - ReturnType -> -export type RestoreTaskResult = RestoreTaskResponse["data"] -export type BranchDurableTaskResponse = Awaited< - ReturnType -> -export type BranchDurableTaskResult = BranchDurableTaskResponse["data"] - -export type RunStatus = NonNullable[number] - -export type RunFilter = SdkRunFilter & { - readonly workflowName?: string - readonly status?: RunStatus -} - -export type ReplayRunOpts = Omit & { - readonly ids?: readonly string[] - readonly filters?: RunFilter -} - -export type ListRunsOpts = - & Omit< - Partial, - "workflowNames" | "statuses" | "onlyTasks" - > - & RunFilter - -const toSdkRunFilter = (options?: RunFilter): SdkRunFilter => ({ - workflowNames: options?.workflowNames?.length - ? [...options.workflowNames] - : options?.workflowName - ? [options.workflowName] - : undefined, - statuses: options?.statuses?.length - ? [...options.statuses] - : options?.status - ? [options.status as NonNullable[number]] - : undefined, - since: options?.since, - until: options?.until, - additionalMetadata: options?.additionalMetadata, -}) - -const toSdkListRunsOpts = ( - options?: ListRunsOpts, -): Partial => { - const { - workflowName, - workflowNames, - status, - statuses, - since, - until, - additionalMetadata, - ...rest - } = options ?? {} - - return { - ...rest, - ...toSdkRunFilter({ - workflowName, - workflowNames, - status, - statuses, - since, - until, - additionalMetadata, - }), - onlyTasks: false, - } -} - -const responseData = (response: R): R["data"] => response.data - -const responseRows = ( - response: R, -): readonly Row[] => response.rows ?? [] - -/** - * Run a workflow and wait for completion - * - * @param workflow - The workflow name to run - * @param input - The input data for the workflow - * @param options - Optional run options - * @returns Effect that resolves with the workflow result - */ -export const runWorkflow = ( - workflow: string, - input: I, - options?: RunOpts, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const result = yield* Effect.tryPromise({ - try: () => client.run(workflow, input, options), - catch: (error) => - new HatchetRunError({ - message: `Workflow "${workflow}" failed to run`, - workflow, - cause: error, - }), - }) - return result - }) - -/** - * Run a workflow without waiting for completion - * - * @param workflow - The workflow name to run - * @param input - The input data for the workflow - * @param options - Optional run options - * @returns Effect that resolves with the workflow run details - */ -export const runWorkflowNoWait = ( - workflow: string, - input: I, - options?: RunOpts, -): Effect.Effect< - WorkflowRunRefResult, - HatchetRunError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const result = yield* Effect.tryPromise({ - try: () => client.runNoWait(workflow, input, options ?? {}), - catch: (error) => - new HatchetRunError({ - message: `Workflow "${workflow}" failed to start`, - workflow, - cause: error, - }), - }) - return result - }) - -/** - * Cancel a running workflow or task - * - * @param runId - The ID of the run to cancel - * @returns Effect that resolves when the run is cancelled - */ -export const cancelRun = ( - runId: string, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - yield* Effect.tryPromise({ - try: () => client.runs.cancel({ ids: [runId] }), - catch: (error) => - new HatchetRunError({ - message: `Failed to cancel run "${runId}"`, - runId, - cause: error, - }), - }) - }) - -export const replayRun = ( - run: string | ReplayRunOpts, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const replayOptions: SdkReplayRunOpts = typeof run === "string" - ? { ids: [run] } - : { - ids: run.ids?.length ? [...run.ids] : undefined, - filters: run.filters ? toSdkRunFilter(run.filters) : undefined, - } - const result = yield* Effect.tryPromise({ - try: () => client.runs.replay(replayOptions), - catch: (error) => - new HatchetRunError({ - message: typeof run === "string" - ? `Failed to replay run "${run}"` - : "Failed to replay runs", - runId: typeof run === "string" ? run : undefined, - cause: error, - }), - }) - - return responseData(result) - }) - -export const restoreTask = ( - taskExternalId: string, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const result = yield* Effect.tryPromise({ - try: () => client.runs.restoreTask(taskExternalId), - catch: (error) => - new HatchetRunError({ - message: `Failed to restore task "${taskExternalId}"`, - runId: taskExternalId, - cause: error, - }), - }) - - return responseData(result) - }) - -export const branchDurableTask = ( - taskExternalId: string, - nodeId: number, - branchId?: number, -): Effect.Effect< - BranchDurableTaskResult, - HatchetRunError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const result = yield* Effect.tryPromise({ - try: () => client.runs.branchDurableTask(taskExternalId, nodeId, branchId), - catch: (error) => - new HatchetRunError({ - message: `Failed to branch durable task "${taskExternalId}" from node ${nodeId}`, - runId: taskExternalId, - cause: error, - }), - }) - - return responseData(result) - }) - -/** - * Get a workflow or task run by ID - * - * @param runId - The ID of the run to get - * @returns Effect that resolves with the run details - */ -export const getRun = ( - runId: string, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const result = yield* Effect.tryPromise({ - try: () => client.runs.get(runId), - catch: (error) => - new HatchetRunError({ - message: `Failed to get run "${runId}"`, - runId, - cause: error, - }), - }) - return result - }) - -/** - * Get the status of a workflow or task run - * - * @param runId - The ID of the run to check - * @returns Effect that resolves with the run status - */ -export const getRunStatus = ( - runId: string, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const result = yield* Effect.tryPromise({ - try: () => client.runs.get_status(runId), - catch: (error) => - new HatchetRunError({ - message: `Failed to get status for run "${runId}"`, - runId, - cause: error, - }), - }) - return result - }) - -/** - * Resolve the task external id for a workflow run. - */ -export const getRunTaskId = ( - runId: string, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const result = yield* Effect.tryPromise({ - try: () => client.runs.getTaskExternalId(runId), - catch: (error) => - new HatchetRunError({ - message: `Failed to resolve task id for run "${runId}"`, - runId, - cause: error, - }), - }) - - return result as string - }) - -/** - * List workflow and task runs - * - * @param options - Options for filtering and paginating runs - * @returns Effect that resolves with the list of runs - */ -export const listRuns = ( - options?: ListRunsOpts, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const result = yield* Effect.tryPromise({ - try: () => client.runs.list(toSdkListRunsOpts(options)), - catch: (error) => - new HatchetWorkflowError({ - message: "Failed to list runs", - cause: error, - }), - }) - return [...responseRows(result)] - }) diff --git a/packages/hatchet/src/clients/webhooks.ts b/packages/hatchet/src/clients/webhooks.ts deleted file mode 100644 index 9807329e..00000000 --- a/packages/hatchet/src/clients/webhooks.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * @effectify/hatchet - Webhooks Client - * - * Effect-first wrappers around the Hatchet SDK webhooks surface. - */ - -import * as Effect from "effect/Effect" -import type { WebhooksClient } from "@hatchet-dev/typescript-sdk" -import type { HatchetClientService } from "../core/client.js" -import { getHatchetClient } from "../core/client.js" -import { HatchetWebhookError } from "../core/error.js" - -/** - * Type audit: - * - Webhook enum/string unions are derived from SDK enums to avoid local shadow values. - * - `ListWebhooksOptions` and `UpdateWebhookOptions` derive from SDK request contracts. - * - `CreateWebhookOptions` / `HatchetWebhookAuth` stay custom because they hide the nested transport `auth` payload behind a flatter public boundary. - * - `HatchetWebhookRecord` stays custom because normalization guarantees required fields. - */ - -type HatchetWebhook = Awaited> -type HatchetWebhookList = Awaited> -type SdkCreateWebhookRequest = Parameters[0] -type SdkWebhookAuth = SdkCreateWebhookRequest["auth"] -type SdkWebhookHmacAuth = Extract< - SdkWebhookAuth, - { readonly algorithm: unknown; readonly encoding: unknown } -> - -export type HatchetWebhookSourceName = `${NonNullable< - HatchetWebhook["sourceName"] ->}` - -export type HatchetWebhookAuthType = `${NonNullable}` - -export type HatchetWebhookHmacAlgorithm = `${SdkWebhookHmacAuth["algorithm"]}` - -export type HatchetWebhookHmacEncoding = `${SdkWebhookHmacAuth["encoding"]}` - -export type HatchetWebhookAuth = - | { - readonly authType: "BASIC" - readonly username: string - readonly password: string - } - | { - readonly authType: "API_KEY" - readonly headerName: string - readonly apiKey: string - } - | { - readonly authType: "HMAC" - readonly algorithm: HatchetWebhookHmacAlgorithm - readonly encoding: HatchetWebhookHmacEncoding - readonly signatureHeaderName: string - readonly signingSecret: string - } - -export interface HatchetWebhookRecord { - readonly webhookId: string - readonly tenantId: string - readonly name: string - readonly sourceName: HatchetWebhookSourceName - readonly eventKeyExpression: string - readonly scopeExpression?: string - readonly staticPayload?: Record - readonly authType: HatchetWebhookAuthType -} - -type SdkListWebhooksOptions = NonNullable< - Parameters[0] -> - -export type ListWebhooksOptions = - & Omit< - SdkListWebhooksOptions, - "sourceNames" - > - & { - readonly sourceNames?: readonly HatchetWebhookSourceName[] - } - -export type CreateWebhookOptions = - & Omit< - SdkCreateWebhookRequest, - "authType" | "auth" | "sourceName" - > - & { - readonly sourceName: HatchetWebhookSourceName - readonly auth: HatchetWebhookAuth - } - -export type UpdateWebhookOptions = NonNullable< - Parameters[1] -> - -type WebhookOperation = "list" | "get" | "create" | "update" | "delete" - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - -const failMissingField = ( - field: string, - context: { - readonly operation: WebhookOperation - readonly webhookName?: string - }, -) => - new HatchetWebhookError({ - message: `Webhook response did not include ${field}`, - operation: context.operation, - webhookName: context.webhookName, - }) - -const normalizeWebhook = ( - webhook: HatchetWebhook, - context: { - readonly operation: WebhookOperation - readonly webhookName?: string - }, -): Effect.Effect => - Effect.gen(function*() { - const webhookId = webhook.metadata?.id - - if (!webhookId) { - return yield* failMissingField("metadata.id", context) - } - - if (!webhook.tenantId) { - return yield* failMissingField("tenantId", context) - } - - if (!webhook.name) { - return yield* failMissingField("name", context) - } - - if (!webhook.sourceName) { - return yield* failMissingField("sourceName", context) - } - - if (!webhook.eventKeyExpression) { - return yield* failMissingField("eventKeyExpression", context) - } - - if (!webhook.authType) { - return yield* failMissingField("authType", context) - } - - return { - webhookId, - tenantId: webhook.tenantId, - name: webhook.name, - sourceName: webhook.sourceName, - eventKeyExpression: webhook.eventKeyExpression, - scopeExpression: webhook.scopeExpression, - staticPayload: isRecord(webhook.staticPayload) - ? webhook.staticPayload - : undefined, - authType: webhook.authType, - } - }) - -const toSdkCreateWebhookOptions = ( - options: CreateWebhookOptions, -): SdkCreateWebhookRequest => { - switch (options.auth.authType) { - case "BASIC": - return { - name: options.name, - sourceName: options.sourceName, - eventKeyExpression: options.eventKeyExpression, - scopeExpression: options.scopeExpression, - staticPayload: options.staticPayload, - authType: options.auth.authType, - auth: { - username: options.auth.username, - password: options.auth.password, - }, - } as SdkCreateWebhookRequest - case "API_KEY": - return { - name: options.name, - sourceName: options.sourceName, - eventKeyExpression: options.eventKeyExpression, - scopeExpression: options.scopeExpression, - staticPayload: options.staticPayload, - authType: options.auth.authType, - auth: { - headerName: options.auth.headerName, - apiKey: options.auth.apiKey, - }, - } as SdkCreateWebhookRequest - case "HMAC": - return { - name: options.name, - sourceName: options.sourceName, - eventKeyExpression: options.eventKeyExpression, - scopeExpression: options.scopeExpression, - staticPayload: options.staticPayload, - authType: options.auth.authType, - auth: { - algorithm: options.auth.algorithm, - encoding: options.auth.encoding, - signatureHeaderName: options.auth.signatureHeaderName, - signingSecret: options.auth.signingSecret, - }, - } as SdkCreateWebhookRequest - } - - return options as never -} - -export const listWebhooks = ( - options?: ListWebhooksOptions, -): Effect.Effect< - readonly HatchetWebhookRecord[], - HatchetWebhookError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const query = options - ? ({ - limit: options.limit, - offset: options.offset, - webhookNames: options.webhookNames - ? [...options.webhookNames] - : undefined, - sourceNames: options.sourceNames - ? [...options.sourceNames] - : undefined, - } as Parameters[0]) - : undefined - const response = yield* Effect.tryPromise({ - try: () => client.webhooks.list(query), - catch: (cause) => - new HatchetWebhookError({ - message: "Failed to list webhooks", - operation: "list", - cause, - }), - }) - - return yield* Effect.forEach( - (response as HatchetWebhookList).rows ?? [], - (webhook) => - normalizeWebhook(webhook, { - operation: "list", - webhookName: webhook.name, - }), - ) - }) - -export const getWebhook = ( - webhookName: string, -): Effect.Effect< - HatchetWebhookRecord, - HatchetWebhookError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const webhook = yield* Effect.tryPromise({ - try: () => client.webhooks.get(webhookName), - catch: (cause) => - new HatchetWebhookError({ - message: `Failed to get webhook "${webhookName}"`, - operation: "get", - webhookName, - cause, - }), - }) - - return yield* normalizeWebhook(webhook as HatchetWebhook, { - operation: "get", - webhookName, - }) - }) - -export const createWebhook = ( - options: CreateWebhookOptions, -): Effect.Effect< - HatchetWebhookRecord, - HatchetWebhookError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const request = toSdkCreateWebhookOptions(options) as Parameters< - typeof client.webhooks.create - >[0] - const webhook = yield* Effect.tryPromise({ - try: () => client.webhooks.create(request), - catch: (cause) => - new HatchetWebhookError({ - message: `Failed to create webhook "${options.name}"`, - operation: "create", - webhookName: options.name, - cause, - }), - }) - - return yield* normalizeWebhook(webhook as HatchetWebhook, { - operation: "create", - webhookName: options.name, - }) - }) - -export const updateWebhook = ( - webhookName: string, - options: UpdateWebhookOptions, -): Effect.Effect< - HatchetWebhookRecord, - HatchetWebhookError, - HatchetClientService -> => - Effect.gen(function*() { - const client = yield* getHatchetClient() - const webhook = yield* Effect.tryPromise({ - try: () => client.webhooks.update(webhookName, options), - catch: (cause) => - new HatchetWebhookError({ - message: `Failed to update webhook "${webhookName}"`, - operation: "update", - webhookName, - cause, - }), - }) - - return yield* normalizeWebhook(webhook as HatchetWebhook, { - operation: "update", - webhookName, - }) - }) - -export const deleteWebhook = ( - webhookName: string, -): Effect.Effect => - Effect.gen(function*() { - const client = yield* getHatchetClient() - yield* Effect.tryPromise({ - try: () => client.webhooks.delete(webhookName), - catch: (cause) => - new HatchetWebhookError({ - message: `Failed to delete webhook "${webhookName}"`, - operation: "delete", - webhookName, - cause, - }), - }) - }) diff --git a/packages/hatchet/src/clients/workflows.ts b/packages/hatchet/src/clients/workflows.ts deleted file mode 100644 index f3e42e56..00000000 --- a/packages/hatchet/src/clients/workflows.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * @effectify/hatchet - Workflows Client - * - * Client for managing workflows in Hatchet - */ - -import * as Effect from "effect/Effect" -import type { WorkflowsClient } from "@hatchet-dev/typescript-sdk" -import type { HatchetClientService } from "../core/client.js" -import { getHatchetClient } from "../core/client.js" -import { HatchetWorkflowError } from "../core/error.js" - -/** - * Type audit: - * - `WorkflowTarget` and `ListWorkflowsOpts` are direct SDK derivations for passthrough calls. - * - Tagged errors stay local for the Effect boundary. - */ -export type WorkflowTarget = Parameters[0] - -/** - * Get a workflow by name - * - * @param name - The name of the workflow to get - * @returns Effect that resolves with the workflow details - */ -export const getWorkflow = (name: string): Effect.Effect => - Effect.gen(function* () { - const client = yield* getHatchetClient() - const result = yield* Effect.tryPromise({ - try: () => client.workflows.get(name), - catch: (error) => - new HatchetWorkflowError({ - message: `Failed to get workflow "${name}"`, - workflowName: name, - cause: error, - }), - }) - - return result as O - }) - -export type ListWorkflowsOpts = Parameters[0] - -/** - * List all workflows - * - * @param options - Options for filtering and paginating workflows - * @returns Effect that resolves with the list of workflows - */ -export const listWorkflows = ( - options?: ListWorkflowsOpts, -): Effect.Effect => - Effect.gen(function* () { - const client = yield* getHatchetClient() - const result = yield* Effect.tryPromise({ - try: () => client.workflows.list(options), - catch: (error) => - new HatchetWorkflowError({ - message: "Failed to list workflows", - cause: error, - }), - }) - - return (result.rows ?? []) as O[] - }) - -export const deleteWorkflow = ( - workflow: WorkflowTarget, -): Effect.Effect => - Effect.gen(function* () { - const client = yield* getHatchetClient() - - yield* Effect.tryPromise({ - try: () => client.workflows.delete(workflow), - catch: (error) => - new HatchetWorkflowError({ - message: `Failed to delete workflow "${typeof workflow === "string" ? workflow : "workflow"}"`, - workflowName: typeof workflow === "string" ? workflow : undefined, - cause: error, - }), - }) - }) diff --git a/packages/hatchet/src/core/client.ts b/packages/hatchet/src/core/client.ts deleted file mode 100644 index 126d33cc..00000000 --- a/packages/hatchet/src/core/client.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * @effectify/hatchet - Hatchet Client - * - * Hatchet SDK client as a Context.Service using Effect v4 - */ - -import * as Effect from "effect/Effect" -import * as Context from "effect/Context" -import * as Layer from "effect/Layer" -import { Hatchet as HatchetClientSDK } from "@hatchet-dev/typescript-sdk" -import { HatchetConfig } from "./config.js" - -type HatchetClientType = InstanceType - -/** - * Context.Service for the Hatchet SDK client - * Renamed to HatchetClientService to avoid conflict with SDK class name - */ -export class HatchetClientService extends Context.Service< - HatchetClientService, - HatchetClientType ->()("HatchetClient") {} - -/** - * Create a Layer that provides the HatchetClientService - * The client is initialized from HatchetConfig - */ -export const HatchetClientLive = Layer.effect(HatchetClientService)( - Effect.gen(function*() { - const config = yield* HatchetConfig - - yield* Effect.logInfo("[Hatchet] Initializing with host:", config.host) - yield* Effect.logInfo("[Hatchet] Token present:", !!config.token) - - // Initialize Hatchet client with token and host - // SDK v1.21.0 API: HatchetClient.init({ token, host_port }) - // This is synchronous, so we use Effect.sync - const hatchet = yield* Effect.sync(() => { - const client = HatchetClientSDK.init({ - token: config.token, - host_port: config.host, - }) - if (!client) { - throw new Error("Hatchet client initialization returned undefined") - } - return client - }) - - yield* Effect.logInfo("[Hatchet] Client initialized successfully!") - - return hatchet - }), -) - -/** - * Convenience function to get the Hatchet client from context - * Usage: yield* getHatchetClient() - */ -export const getHatchetClient = (): Effect.Effect< - HatchetClientType, - never, - HatchetClientService -> => Effect.service(HatchetClientService) diff --git a/packages/hatchet/src/core/config.ts b/packages/hatchet/src/core/config.ts deleted file mode 100644 index 665841d7..00000000 --- a/packages/hatchet/src/core/config.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * @effectify/hatchet - Configuration - * - * Hatchet configuration using Effect v4 Config and Context.Service - */ - -import * as Effect from "effect/Effect" -import * as Context from "effect/Context" -import * as Layer from "effect/Layer" -import * as Config from "effect/Config" -import * as Schema from "effect/Schema" - -/** - * Configuration schema for Hatchet - * Uses Schema from the main 'effect' package - */ -const HatchetConfigSchema = Schema.Struct({ - token: Schema.String, - host: Schema.String, - namespace: Schema.optional(Schema.String), -}) - -/** - * Type extracted from the schema - */ -export type HatchetConfigType = Schema.Schema.Type - -/** - * Context.Service for Hatchet configuration - * This allows injecting config via Effect's dependency injection - */ -export class HatchetConfig extends Context.Service< - HatchetConfig, - HatchetConfigType ->()("HatchetConfig") {} - -/** - * Create a Layer that provides the HatchetConfig service - * from a config object - */ -export const HatchetConfigLayer = ( - config: HatchetConfigType, -): Layer.Layer => Layer.succeed(HatchetConfig, config) - -/** - * Default configuration values - */ -export const defaultHatchetConfig = { - host: "http://localhost:8080" as const, -} - -/** - * Create a Layer from environment variables - * Uses Config.Wrap for type-safe environment config - * and applies default values - */ -export const HatchetConfigLayerFromEnv = ( - config: Config.Wrap, -): Layer.Layer => - Layer.effect(HatchetConfig)( - Effect.gen(function*() { - const unwrapped = yield* Config.unwrap(config) - return { - host: unwrapped.host ?? defaultHatchetConfig.host, - token: unwrapped.token, - namespace: unwrapped.namespace, - } - }), - ) diff --git a/packages/hatchet/src/core/context.ts b/packages/hatchet/src/core/context.ts deleted file mode 100644 index bfa0432d..00000000 --- a/packages/hatchet/src/core/context.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @effectify/hatchet - Hatchet Step Context - * - * Context.Service for injecting Hatchet context into Effect tasks - */ - -import * as Context from "effect/Context" -import type { Context as SdkContext } from "@hatchet-dev/typescript-sdk" - -export type HatchetTaskContext< - I = unknown, - U extends Record = Record, -> = SdkContext - -/** - * Context.Service for the Hatchet step context - * This is injected at runtime by the effectifier when executing a task - */ -export class HatchetStepContext extends Context.Service< - HatchetStepContext, - HatchetTaskContext ->()("HatchetStepContext") {} diff --git a/packages/hatchet/src/core/error.ts b/packages/hatchet/src/core/error.ts deleted file mode 100644 index f3de65d6..00000000 --- a/packages/hatchet/src/core/error.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @effectify/hatchet - Core Error Types - * - * Error types for Hatchet integration using Effect v4 Data.TaggedError - */ - -import * as Data from "effect/Data" - -/** - * Base error class for all Hatchet-related errors - * Uses Data.TaggedError for discriminated error handling - */ -export class HatchetError extends Data.TaggedError("HatchetError")<{ - readonly message: string - readonly cause?: unknown -}> {} - -/** - * Error when Hatchet SDK initialization fails - */ -export class HatchetInitError extends Data.TaggedError("HatchetInitError")<{ - readonly message: string - readonly cause?: unknown -}> {} - -/** - * Error when workflow/task execution fails - */ -export class HatchetExecutionError extends Data.TaggedError( - "HatchetExecutionError", -)<{ - readonly message: string - readonly taskName?: string - readonly cause?: unknown -}> {} - -/** - * Error when worker registration fails - */ -export class HatchetWorkerError extends Data.TaggedError("HatchetWorkerError")<{ - readonly message: string - readonly workerName?: string - readonly cause?: unknown -}> {} - -/** - * Error when context operations fail - */ -export class HatchetContextError extends Data.TaggedError( - "HatchetContextError", -)<{ - readonly message: string - readonly operation: "input" | "parentOutput" | "log" - readonly cause?: unknown -}> {} - -/** - * Error when a workflow run fails - */ -export class HatchetRunError extends Data.TaggedError("HatchetRunError")<{ - readonly message: string - readonly workflow?: string - readonly runId?: string - readonly cause?: unknown -}> {} - -/** - * Error when an observability operation fails - */ -export class HatchetObservabilityError extends Data.TaggedError( - "HatchetObservabilityError", -)<{ - readonly message: string - readonly operation: "logs" | "metrics" - readonly endpoint: string - readonly taskId?: string - readonly tenantId?: string - readonly cause?: unknown -}> {} - -/** - * Error when a workflow operation fails (create, get, list) - */ -export class HatchetWorkflowError extends Data.TaggedError( - "HatchetWorkflowError", -)<{ - readonly message: string - readonly workflowName?: string - readonly cause?: unknown -}> {} - -/** - * Error when an event operation fails - */ -export class HatchetEventError extends Data.TaggedError("HatchetEventError")<{ - readonly message: string - readonly key?: string - readonly eventId?: string - readonly cause?: unknown -}> {} - -/** - * Error when a schedule operation fails - */ -export class HatchetScheduleError extends Data.TaggedError( - "HatchetScheduleError", -)<{ - readonly message: string - readonly scheduleId?: string - readonly workflowName?: string - readonly cause?: unknown -}> {} - -/** - * Error when a cron operation fails - */ -export class HatchetCronError extends Data.TaggedError("HatchetCronError")<{ - readonly message: string - readonly cronId?: string - readonly workflowName?: string - readonly cause?: unknown -}> {} - -/** - * Error when a webhook operation fails - */ -export class HatchetWebhookError extends Data.TaggedError( - "HatchetWebhookError", -)<{ - readonly message: string - readonly operation: "list" | "get" | "create" | "update" | "delete" - readonly webhookName?: string - readonly cause?: unknown -}> {} - -/** - * Error when a rate-limit operation fails - */ -export class HatchetRateLimitError extends Data.TaggedError( - "HatchetRateLimitError", -)<{ - readonly message: string - readonly operation: "list" | "upsert" - readonly key?: string - readonly cause?: unknown -}> {} - -/** - * Error when a filter operation fails - */ -export class HatchetFilterError extends Data.TaggedError("HatchetFilterError")<{ - readonly message: string - readonly operation: "list" | "create" | "get" | "delete" - readonly filterId?: string - readonly workflowId?: string - readonly cause?: unknown -}> {} diff --git a/packages/hatchet/src/core/index.ts b/packages/hatchet/src/core/index.ts deleted file mode 100644 index d9814859..00000000 --- a/packages/hatchet/src/core/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @effectify/hatchet - Core Module Exports - */ - -export { - HatchetContextError, - HatchetCronError, - HatchetError, - HatchetEventError, - HatchetExecutionError, - HatchetFilterError, - HatchetInitError, - HatchetObservabilityError, - HatchetRateLimitError, - HatchetRunError, - HatchetWebhookError, - HatchetWorkerError, - HatchetWorkflowError, -} from "./error.js" - -export { - defaultHatchetConfig, - HatchetConfig, - HatchetConfigLayer, - HatchetConfigLayerFromEnv, - type HatchetConfigType, -} from "./config.js" - -export { HatchetClientLive, HatchetClientService } from "./client.js" - -export { HatchetStepContext } from "./context.js" -export type { HatchetTaskContext } from "./context.js" diff --git a/packages/hatchet/src/logging/hatchet-logger.ts b/packages/hatchet/src/logging/hatchet-logger.ts deleted file mode 100644 index c7ca360a..00000000 --- a/packages/hatchet/src/logging/hatchet-logger.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @effectify/hatchet - Hatchet Logger - * - * Custom Effect Logger that automatically syncs logs to Hatchet UI. - * When Effect.log() is called inside a Hatchet task, logs appear in Hatchet dashboard. - */ - -import * as Effect from "effect/Effect" -import * as Context from "effect/Context" -import * as Logger from "effect/Logger" -import * as Option from "effect/Option" -import { HatchetStepContext } from "../core/context.js" - -const defaultFormat = (level: string, message: string): string => `[${level}] ${message}` - -const stringifyMessage = (message: unknown): string => typeof message === "string" ? message : String(message) - -const makeConsoleFallback = ( - format: (level: string, message: string) => string, -): Logger.Logger => - Logger.withConsoleLog( - Logger.make(({ logLevel, message }) => format(String(logLevel), stringifyMessage(message))), - ) - -const makeConfiguredHatchetLogger = ( - format: (level: string, message: string) => string, - shouldConsole: boolean, -): Logger.Logger => { - const fallback = makeConsoleFallback(format) - - return Logger.make((options) => { - const formatted = format( - String(options.logLevel), - stringifyMessage(options.message), - ) - - const hatchetCtx = Context.getOption(options.fiber.context, HatchetStepContext) - - if (Option.isSome(hatchetCtx)) { - try { - hatchetCtx.value.log(formatted) - } catch { - if (shouldConsole) { - fallback.log(options) - } - } - return - } - - if (shouldConsole) { - fallback.log(options) - } - }) -} - -/** - * Creates a Hatchet-aware logger that: - * - If inside a Hatchet task: forwards logs to Hatchet UI via ctx.log() - * - Otherwise: behaves as the default console logger - */ -export const makeHatchetLogger = (): Logger.Logger => makeConfiguredHatchetLogger(defaultFormat, true) - -/** - * Default Hatchet logger instance - */ -export const HatchetLogger: Logger.Logger = makeHatchetLogger() - -/** - * Runs an Effect with the Hatchet logger enabled. - * - * @example - * ```typescript - * const result = await Effect.runPromise( - * withHatchetLogger( - * Effect.gen(function*() { - * yield* Effect.log("Hello from Effect!") // Appears in Hatchet UI - * }) - * ) - * ) - * ``` - */ -export const withHatchetLogger = ( - effect: Effect.Effect, -): Effect.Effect => Effect.withLogger(effect, HatchetLogger) - -/** - * Creates a custom Hatchet logger with additional options. - */ -export const createHatchetLogger = ( - options: { - /** - * Custom format for log messages - */ - format?: (level: string, message: string) => string - /** - * Whether to also log to console (default: true) - */ - console?: boolean - } = {}, -): Logger.Logger => { - const { format = defaultFormat, console: shouldConsole = true } = options - - return makeConfiguredHatchetLogger(format, shouldConsole) -} diff --git a/packages/hatchet/src/logging/index.ts b/packages/hatchet/src/logging/index.ts deleted file mode 100644 index fcaba7fa..00000000 --- a/packages/hatchet/src/logging/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @effectify/hatchet - Logging Module - * - * Automatic log synchronization between Effect.log() and Hatchet UI. - * When Effect.log() is called inside a Hatchet task, logs appear in Hatchet dashboard. - */ - -export { createHatchetLogger, HatchetLogger, makeHatchetLogger, withHatchetLogger } from "./hatchet-logger.js" diff --git a/packages/hatchet/src/schema/get-validated-input.ts b/packages/hatchet/src/schema/get-validated-input.ts deleted file mode 100644 index 66ce4404..00000000 --- a/packages/hatchet/src/schema/get-validated-input.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * @effectify/hatchet - Schema Validation - * - * Utilities for validating workflow input using Effect Schema. - */ - -import * as Effect from "effect/Effect" -import * as Option from "effect/Option" -import * as Schema from "effect/Schema" -import { HatchetStepContext } from "../core/context.js" - -/** - * Extracts and validates the workflow input against a Schema. - * - * This is the main way to get type-safe input in your tasks. - * - * @param schema - The Schema to validate against - * @returns Effect that resolves to the parsed input - * - * @example - * ```typescript - * const UserSchema = Schema.Struct({ - * userId: Schema.String, - * email: Schema.String.pipe(Schema.email()), - * }) - * - * const myTask = task( - * { name: "process-user" }, - * Effect.gen(function*() { - * const input = yield* getValidatedInput(UserSchema) - * // input is now typed as { userId: string, email: string } - * return yield* processUser(input) - * }) - * ) - * ``` - */ -export const getValidatedInput = ( - schema: Schema.Schema, -): Effect.Effect => - Effect.flatMap(Effect.service(HatchetStepContext), (ctx) => { - // SDK v1: input is a property, not ctx.workflowInput() - const rawInput = ctx.input - - // Use decodeUnknownOption - returns Option, no Effect context needed - // We cast schema to any because the Schema type system is complex - // and decodeUnknownOption has constraints we don't need - const decodeOption = Schema.decodeUnknownOption as ( - s: Schema.Schema, - ) => (input: unknown) => Option.Option - const option = decodeOption(schema)(rawInput) - - // Convert Option to Effect - None becomes SchemaError failure - return Option.match(option, { - onNone: () => { - // Create a simple error message - SchemaError expects specific types - const error = new Error("Input validation failed") - return Effect.fail( - Object.assign(error, { _tag: "SchemaError" }) as Schema.SchemaError, - ) - }, - onSome: (a) => Effect.succeed(a), - }) - }) - -/** - * Extracts raw input without validation. - * Use this if you want to validate manually or don't need validation. - * - * @returns The raw input from the Hatchet context - */ -export const getRawInput = (): Effect.Effect< - unknown, - never, - HatchetStepContext -> => Effect.map(Effect.service(HatchetStepContext), (ctx) => ctx.input) - -/** - * Creates a decoder Effect from a schema. - * Useful for composing with other Effects. - * - * @param schema - The Schema to decode with - * @returns Effect that decodes the input - */ -export const decodeInput = ( - schema: Schema.Schema, -): Effect.Effect => getValidatedInput(schema) diff --git a/packages/hatchet/src/schema/index.ts b/packages/hatchet/src/schema/index.ts deleted file mode 100644 index a304f0c3..00000000 --- a/packages/hatchet/src/schema/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @effectify/hatchet - Schema Module - * - * Input validation using Effect Schema. - */ - -export { decodeInput, getRawInput, getValidatedInput } from "./get-validated-input.js" diff --git a/packages/hatchet/src/testing/index.ts b/packages/hatchet/src/testing/index.ts index b6af0b58..eed1f9de 100644 --- a/packages/hatchet/src/testing/index.ts +++ b/packages/hatchet/src/testing/index.ts @@ -1,24 +1 @@ -/** - * @effectify/hatchet - Testing Module - * - * Utilities for testing workflows without external dependencies. - */ - -export { - createDefaultMockLayer, - createMockContext, - createMockLayer, - runWithMockContext, - testTask, - testTaskExit, -} from "./mock-context.js" - -export { - createMockHatchetClient, - createMockHatchetClientLayer, - MockHatchetClientLayer, - TestHatchetConfigLayer, - TestHatchetLayer, -} from "./mock-client.js" - export { layerInMemory } from "../Hatchet.js" diff --git a/packages/hatchet/src/testing/mock-client.ts b/packages/hatchet/src/testing/mock-client.ts deleted file mode 100644 index d1ee810a..00000000 --- a/packages/hatchet/src/testing/mock-client.ts +++ /dev/null @@ -1,456 +0,0 @@ -/** - * @effectify/hatchet - Mock Hatchet Client - * - * Mock implementations of Hatchet client for testing. - */ - -import * as Layer from "effect/Layer" -import { Hatchet as HatchetClientSDK } from "@hatchet-dev/typescript-sdk" -import type { PushEventOptions } from "../clients/events.js" -import { HatchetClientService } from "../core/client.js" -import { HatchetConfig } from "../core/config.js" - -type HatchetClientType = InstanceType - -type MockHatchetRunsClient = { - readonly cancel: (options: unknown) => Promise - readonly replay: (options: unknown) => Promise - readonly restoreTask: (taskExternalId: string) => Promise - readonly branchDurableTask: ( - taskExternalId: string, - nodeId: number, - branchId?: number, - ) => Promise - readonly get: (runId: string) => Promise - readonly get_status: (runId: string) => Promise - readonly getTaskExternalId: (runId: string) => Promise - readonly list: (options?: unknown) => Promise<{ rows?: unknown[] }> -} - -type MockHatchetLogsClient = { - readonly listTask: ( - taskId: string, - options?: unknown, - ) => Promise<{ rows?: unknown[] }> - readonly listTenant: (options?: unknown) => Promise<{ rows?: unknown[] }> -} - -type MockHatchetMetricsClient = { - readonly getTaskMetrics: (options?: unknown) => Promise<{ - byStatus: { - PENDING: number - RUNNING: number - COMPLETED: number - FAILED: number - CANCELLED: number - } - }> - readonly getQueueMetrics: () => Promise<{ - total: { queued: number; running: number; pending: number } - workflowBreakdown: Record< - string, - { queued: number; running: number; pending: number } - > - stepRun: Record - }> -} - -type MockHatchetWorkflowsClient = { - readonly get: (name: string) => Promise - readonly list: (options?: unknown) => Promise<{ workflows: unknown[] }> - readonly delete: (workflow: unknown) => Promise -} - -type MockHatchetSchedulesClient = { - readonly create: (workflow: string, options: unknown) => Promise - readonly get: (scheduleId: string) => Promise - readonly list: (options?: unknown) => Promise<{ rows?: unknown[] }> - readonly delete: (scheduleId: string) => Promise -} - -type MockHatchetCronsClient = { - readonly create: (workflow: string, options: unknown) => Promise - readonly get: (cronId: string) => Promise - readonly list: (options?: unknown) => Promise<{ rows?: unknown[] }> - readonly delete: (cronId: string) => Promise -} - -type MockHatchetWebhooksClient = { - readonly list: (options?: unknown) => Promise<{ rows?: unknown[] }> - readonly get: (webhookName: string) => Promise - readonly create: (options: unknown) => Promise - readonly update: (webhookName: string, options?: unknown) => Promise - readonly delete: (webhookName: string) => Promise -} - -type MockHatchetRateLimitsClient = { - readonly list: (options?: unknown) => Promise<{ rows?: unknown[] }> - readonly upsert: (options: unknown) => Promise -} - -type MockHatchetFiltersClient = { - readonly list: (options?: unknown) => Promise<{ rows?: unknown[] }> - readonly get: (filterId: string) => Promise - readonly create: (options: unknown) => Promise - readonly delete: (filterId: string) => Promise -} - -type MockWorkerInstance = { - readonly registerWorkflows: (workflows?: unknown[]) => Promise - readonly start: () => Promise -} - -/** - * Mock HatchetClient type for testing - * eslint-disable-next-line @typescript-eslint/no-explicit-any - */ -export type MockHatchetClient = HatchetClientType & { - readonly tenantId: string - readonly run: ( - workflow: string, - input: unknown, - options?: unknown, - ) => Promise - readonly runNoWait: ( - workflow: string, - input: unknown, - options?: unknown, - ) => Promise - readonly events: { - readonly push: ( - key: string, - payload: unknown, - options?: PushEventOptions, - ) => Promise - readonly list: (options?: unknown) => Promise - } - readonly api: { - readonly v1EventGet: ( - tenantId: string, - eventId: string, - ) => Promise<{ data: unknown }> - readonly v1LogLineList: ( - taskId: string, - query?: unknown, - ) => Promise<{ data: { rows?: unknown[] } }> - readonly v1TenantLogLineList: ( - tenantId: string, - query?: unknown, - ) => Promise<{ data: { rows?: unknown[] } }> - readonly v1TaskListStatusMetrics: ( - tenantId: string, - query: unknown, - ) => Promise<{ data: unknown }> - readonly tenantGetQueueMetrics: ( - tenantId: string, - query?: unknown, - ) => Promise<{ data: unknown }> - readonly tenantGetStepRunQueueMetrics: ( - tenantId: string, - ) => Promise<{ data: unknown }> - } - readonly logs: MockHatchetLogsClient - readonly metrics: MockHatchetMetricsClient - readonly runs: MockHatchetRunsClient - readonly crons: MockHatchetCronsClient - readonly ratelimits: MockHatchetRateLimitsClient - readonly filters: MockHatchetFiltersClient - readonly webhooks: MockHatchetWebhooksClient - readonly scheduled: MockHatchetSchedulesClient - readonly workflows: MockHatchetWorkflowsClient - readonly worker: ( - name: string, - options?: unknown, - ) => Promise -} - -export interface MockHatchetClientOverrides { - readonly tenantId?: string - readonly run?: MockHatchetClient["run"] - readonly runNoWait?: MockHatchetClient["runNoWait"] - readonly events?: Partial - readonly api?: Partial - readonly logs?: Partial - readonly metrics?: Partial - readonly runs?: Partial - readonly crons?: Partial - readonly ratelimits?: Partial - readonly filters?: Partial - readonly webhooks?: Partial - readonly scheduled?: Partial - readonly workflows?: Partial - readonly worker?: MockHatchetClient["worker"] -} - -const unimplemented = (method: string) => async () => { - throw new Error(`Mock Hatchet client method not implemented: ${method}`) -} - -const asMockApiMethod = (value: unknown): T => value as T - -/** - * Create a mock HatchetClient for testing - */ -export const createMockHatchetClient = ( - overrides: MockHatchetClientOverrides = {}, -): MockHatchetClient => { - const baseClient = { - tenantId: "test-tenant-id", - run: unimplemented("run"), - runNoWait: unimplemented("runNoWait"), - events: { - push: unimplemented("events.push"), - list: async () => ({ rows: [] }), - }, - api: { - v1EventGet: unimplemented("api.v1EventGet"), - v1LogLineList: asMockApiMethod( - async () => ({ data: { rows: [] } }), - ), - v1TenantLogLineList: asMockApiMethod< - MockHatchetClient["api"]["v1TenantLogLineList"] - >(async () => ({ data: { rows: [] } })), - v1TaskListStatusMetrics: asMockApiMethod< - MockHatchetClient["api"]["v1TaskListStatusMetrics"] - >(async () => ({ data: [] })), - tenantGetQueueMetrics: asMockApiMethod< - MockHatchetClient["api"]["tenantGetQueueMetrics"] - >(async () => ({ data: { total: {}, workflow: {} } })), - tenantGetStepRunQueueMetrics: asMockApiMethod< - MockHatchetClient["api"]["tenantGetStepRunQueueMetrics"] - >(async () => ({ data: { queues: {} } })), - }, - logs: { - listTask: async () => ({ rows: [] }), - listTenant: async () => ({ rows: [] }), - }, - metrics: { - getTaskMetrics: async () => ({ - byStatus: { - PENDING: 0, - RUNNING: 0, - COMPLETED: 0, - FAILED: 0, - CANCELLED: 0, - }, - }), - getQueueMetrics: async () => ({ - total: { queued: 0, running: 0, pending: 0 }, - workflowBreakdown: {}, - stepRun: {}, - }), - }, - runs: { - cancel: unimplemented("runs.cancel"), - replay: unimplemented("runs.replay"), - restoreTask: unimplemented( - "runs.restoreTask", - ) as MockHatchetClient["runs"]["restoreTask"], - branchDurableTask: unimplemented( - "runs.branchDurableTask", - ) as MockHatchetClient["runs"]["branchDurableTask"], - get: unimplemented("runs.get"), - get_status: unimplemented("runs.get_status"), - getTaskExternalId: unimplemented( - "runs.getTaskExternalId", - ) as MockHatchetRunsClient["getTaskExternalId"], - list: (async () => ({ - rows: [], - pagination: {} as never, - })) as MockHatchetClient["runs"]["list"], - }, - crons: { - create: unimplemented("crons.create"), - get: unimplemented("crons.get"), - list: async () => ({ rows: [] }), - delete: (async () => undefined) as MockHatchetClient["crons"]["delete"], - }, - ratelimits: { - list: async () => ({ rows: [] }), - upsert: unimplemented( - "ratelimits.upsert", - ) as MockHatchetClient["ratelimits"]["upsert"], - }, - filters: { - list: async () => ({ rows: [] }), - get: unimplemented("filters.get"), - create: unimplemented("filters.create"), - delete: unimplemented( - "filters.delete", - ) as MockHatchetClient["filters"]["delete"], - }, - webhooks: { - list: async () => ({ rows: [] }), - get: unimplemented("webhooks.get"), - create: unimplemented("webhooks.create"), - update: unimplemented( - "webhooks.update", - ) as MockHatchetClient["webhooks"]["update"], - delete: unimplemented( - "webhooks.delete", - ) as MockHatchetClient["webhooks"]["delete"], - }, - scheduled: { - create: unimplemented("scheduled.create"), - get: unimplemented("scheduled.get"), - list: async () => ({ rows: [] }), - delete: (async () => undefined) as MockHatchetClient["scheduled"]["delete"], - }, - workflows: { - get: unimplemented("workflows.get"), - list: async () => ({ workflows: [] }), - delete: (async () => undefined) as MockHatchetClient["workflows"]["delete"], - }, - worker: (async () => ({ - registerWorkflows: async () => {}, - start: async () => {}, - })) as unknown as MockHatchetClient["worker"], - } satisfies MockHatchetClientOverrides - - const logs = { - ...baseClient.logs, - ...overrides.logs, - } - - const metrics = { - ...baseClient.metrics, - ...overrides.metrics, - } - - return { - ...baseClient, - ...overrides, - tenantId: overrides.tenantId ?? baseClient.tenantId, - run: overrides.run ?? baseClient.run, - runNoWait: overrides.runNoWait ?? baseClient.runNoWait, - events: { - ...baseClient.events, - ...overrides.events, - }, - api: { - ...baseClient.api, - v1LogLineList: overrides.api?.v1LogLineList ?? - asMockApiMethod( - async (taskId: string, query?: unknown) => ({ - data: await logs.listTask(taskId, query), - }), - ), - v1TenantLogLineList: overrides.api?.v1TenantLogLineList ?? - asMockApiMethod( - async (tenantId: string, query?: unknown) => { - void tenantId - return { data: await logs.listTenant(query) } - }, - ), - v1TaskListStatusMetrics: overrides.api?.v1TaskListStatusMetrics ?? - asMockApiMethod( - async (tenantId: string, query: unknown) => { - void tenantId - return { data: await metrics.getTaskMetrics(query) } - }, - ), - tenantGetQueueMetrics: overrides.api?.tenantGetQueueMetrics ?? - asMockApiMethod( - async (tenantId: string) => { - void tenantId - const data = await metrics.getQueueMetrics() - - return { - data: { - total: { - numQueued: data.total.queued, - numRunning: data.total.running, - numPending: data.total.pending, - }, - workflow: Object.entries(data.workflowBreakdown).reduce< - Record - >((acc, [key, value]) => { - const counts = value as { - queued: number - running: number - pending: number - } - acc[key] = { - numQueued: counts.queued, - numRunning: counts.running, - numPending: counts.pending, - } - return acc - }, {}), - }, - } - }, - ), - tenantGetStepRunQueueMetrics: overrides.api?.tenantGetStepRunQueueMetrics ?? - asMockApiMethod< - MockHatchetClient["api"]["tenantGetStepRunQueueMetrics"] - >(async (tenantId: string) => { - void tenantId - const data = await metrics.getQueueMetrics() - return { data: { queues: data.stepRun } } - }), - ...overrides.api, - }, - logs, - metrics, - runs: { - ...baseClient.runs, - ...overrides.runs, - }, - crons: { - ...baseClient.crons, - ...overrides.crons, - }, - ratelimits: { - ...baseClient.ratelimits, - ...overrides.ratelimits, - }, - filters: { - ...baseClient.filters, - ...overrides.filters, - }, - webhooks: { - ...baseClient.webhooks, - ...overrides.webhooks, - }, - scheduled: { - ...baseClient.scheduled, - ...overrides.scheduled, - }, - workflows: { - ...baseClient.workflows, - ...overrides.workflows, - }, - worker: overrides.worker ?? baseClient.worker, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as MockHatchetClient -} - -export const createMockHatchetClientLayer = ( - overrides: MockHatchetClientOverrides = {}, -) => Layer.succeed(HatchetClientService, createMockHatchetClient(overrides)) - -/** - * Layer that provides a mock HatchetClientService - */ -export const MockHatchetClientLayer = Layer.succeed( - HatchetClientService, - createMockHatchetClient(), -) - -/** - * Layer that provides test HatchetConfig - */ -export const TestHatchetConfigLayer = Layer.succeed(HatchetConfig, { - host: "localhost:7077", - token: "test-token", - namespace: undefined, -}) - -/** - * Combined layer for testing with mock client - */ -export const TestHatchetLayer = Layer.mergeAll( - TestHatchetConfigLayer, - MockHatchetClientLayer, -) diff --git a/packages/hatchet/src/testing/mock-context.ts b/packages/hatchet/src/testing/mock-context.ts deleted file mode 100644 index afdc9c88..00000000 --- a/packages/hatchet/src/testing/mock-context.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * @effectify/hatchet - Testing Utilities - * - * Utilities for testing workflows without external dependencies. - */ - -import * as Effect from "effect/Effect" -import type * as Exit from "effect/Exit" -import * as Layer from "effect/Layer" -import { HatchetStepContext, type HatchetTaskContext } from "../core/context.js" - -type MockHatchetTaskContext> = Pick< - HatchetTaskContext, - "input" | "taskName" | "workflowName" | "workflowRunId" | "retryCount" | "parentOutput" | "log" | "logger" -> - -const toHatchetTaskContext = >( - context: MockHatchetTaskContext, -): HatchetTaskContext => context as unknown as HatchetTaskContext - -const createProvidedMockLayer = (mockContext: HatchetTaskContext): Layer.Layer => - Layer.succeed(HatchetStepContext, mockContext) - -/** - * Creates a mock HatchetStepContext for testing. - * - * @param options - Optional configuration for the mock context - * @returns A mock context object - */ -export const createMockContext = = Record>( - options: { - readonly input?: I - readonly taskName?: string - readonly workflowName?: string - readonly workflowRunId?: string - readonly retryCount?: number - } = {}, -): HatchetTaskContext => { - const { - input, - taskName = "test-task", - workflowName = "test-workflow", - workflowRunId = "test-run-id", - retryCount = 0, - } = options - - const providedInput = (input ?? {}) as I - - const mockContext: MockHatchetTaskContext = { - input: providedInput, - taskName: () => taskName, - workflowName: () => workflowName, - workflowRunId: () => workflowRunId, - retryCount: () => retryCount, - parentOutput: async () => null as never, - log: async (_message) => { - // no-op for testing - }, - logger: { - info: async () => {}, - debug: async () => {}, - warn: async () => {}, - error: async () => {}, - util: () => {}, - }, - } - - return toHatchetTaskContext(mockContext) -} - -/** - * Creates a Layer that provides a mock HatchetStepContext. - * - * @param mockContext - The mock context to provide - * @returns A Layer that provides the mock context - */ -export const createMockLayer = (mockContext: HatchetTaskContext): Layer.Layer => - createProvidedMockLayer(mockContext) - -/** - * Creates a Layer with a default mock context. - * - * @returns A Layer with default mock context - */ -export const createDefaultMockLayer = (): Layer.Layer => - createProvidedMockLayer(createMockContext()) - -/** - * Runs an Effect with a mock HatchetStepContext using provide. - * - * @param effect - The Effect to run - * @param mockContext - Optional mock context (creates default if not provided) - * @returns Effect with the context provided - */ -export const runWithMockContext = ( - effect: Effect.Effect, - mockContext?: HatchetTaskContext, -): Effect.Effect => { - const ctx = mockContext ?? createMockContext() - const mockLayer = createProvidedMockLayer(ctx) - return Effect.provide(effect, mockLayer) -} - -/** - * A simple test runner that executes an Effect with mock context. - * - * @param effect - The Effect to test - * @param mockContext - Optional mock context - * @returns Promise with the result - */ -export const testTask = async ( - effect: Effect.Effect, - mockContext?: HatchetTaskContext, -): Promise => { - const ctx = mockContext ?? createMockContext() - const mockLayer = createProvidedMockLayer(ctx) - const effectWithContext = Effect.provide(effect, mockLayer) - return Effect.runPromise(effectWithContext) -} - -/** - * A test runner that returns the Exit for more detailed assertions. - * - * @param effect - The Effect to test - * @param mockContext - Optional mock context - * @returns Promise with the Exit - */ -export const testTaskExit = async ( - effect: Effect.Effect, - mockContext?: HatchetTaskContext, -): Promise> => { - const ctx = mockContext ?? createMockContext() - const mockLayer = createProvidedMockLayer(ctx) - const effectWithContext = Effect.provide(effect, mockLayer) - return Effect.runPromiseExit(effectWithContext) -} diff --git a/packages/hatchet/tests/types/legacy-exports.ts b/packages/hatchet/tests/types/legacy-exports.ts index c9ffcfb1..2a4ba71d 100644 --- a/packages/hatchet/tests/types/legacy-exports.ts +++ b/packages/hatchet/tests/types/legacy-exports.ts @@ -1,4 +1,5 @@ import * as PublicApi from "../../src/index.js" +import * as TestingApi from "../../src/testing/index.js" // @ts-expect-error removed alpha root export const removedWorkflow = PublicApi.workflow @@ -31,6 +32,10 @@ const liveOptions: PublicApi.Hatchet.LiveOptions = { } // @ts-expect-error HatchetRuntime was implementation plumbing. const runtime = PublicApi.HatchetRuntime +// @ts-expect-error legacy mock-client helpers were removed from the testing subpath. +const mockClient = TestingApi.createMockHatchetClient +// @ts-expect-error legacy mock-context helpers were removed from the testing subpath. +const mockContext = TestingApi.createMockContext void removedWorkflow void removedTask @@ -45,8 +50,22 @@ void startWorker void directOptions void liveOptions void runtime +void mockClient +void mockContext // @ts-expect-error removed alpha public subpath await import("../../src/workflow/index.js") // @ts-expect-error removed alpha public subpath await import("../../src/effectifier/index.js") +// @ts-expect-error removed 0.1 legacy client graph +await import("../../src/clients/index.js") +// @ts-expect-error removed 0.1 legacy core graph +await import("../../src/core/index.js") +// @ts-expect-error removed 0.1 legacy logging graph +await import("../../src/logging/index.js") +// @ts-expect-error removed 0.1 legacy schema graph +await import("../../src/schema/index.js") +// @ts-expect-error removed 0.1 mock-client deep path +await import("../../src/testing/mock-client.js") +// @ts-expect-error removed 0.1 mock-context deep path +await import("../../src/testing/mock-context.js") diff --git a/packages/hatchet/tests/types/legacy-time-wrapper-removal.ts b/packages/hatchet/tests/types/legacy-time-wrapper-removal.ts deleted file mode 100644 index 669c703b..00000000 --- a/packages/hatchet/tests/types/legacy-time-wrapper-removal.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Hatchet } from "../../src/index.js" -import { pushEvent } from "../../src/clients/index.js" -import type * as LegacyClients from "../../src/clients/index.js" - -type ScheduleWrapperRemoved = "createSchedule" extends keyof typeof LegacyClients ? never : true -type CronWrapperRemoved = "createCron" extends keyof typeof LegacyClients ? never : true - -const scheduleWrapperRemoved: ScheduleWrapperRemoved = true -const cronWrapperRemoved: CronWrapperRemoved = true - -void Hatchet -void pushEvent -void scheduleWrapperRemoved -void cronWrapperRemoved diff --git a/packages/hatchet/tests/types/testing-exports.ts b/packages/hatchet/tests/types/testing-exports.ts new file mode 100644 index 00000000..157a61c4 --- /dev/null +++ b/packages/hatchet/tests/types/testing-exports.ts @@ -0,0 +1,36 @@ +import type * as Layer from "effect/Layer" +import * as BuiltHatchet from "../../dist/src/Hatchet.js" +import * as BuiltTesting from "../../dist/src/testing/index.js" +import * as SourceHatchet from "../../src/Hatchet.js" +import * as SourceTesting from "../../src/testing/index.js" + +const sourceLayer: Layer.Layer = SourceTesting.layerInMemory +const builtLayer: Layer.Layer = BuiltTesting.layerInMemory +const sourceIdentity: typeof SourceHatchet.layerInMemory = SourceTesting.layerInMemory +const builtIdentity: typeof BuiltHatchet.layerInMemory = BuiltTesting.layerInMemory + +type SourceTestingExportsAreExact = Exclude extends never ? true : never +type BuiltTestingExportsAreExact = Exclude extends never ? true : never + +const sourceTestingExportsAreExact: SourceTestingExportsAreExact = true +const builtTestingExportsAreExact: BuiltTestingExportsAreExact = true + +// @ts-expect-error legacy mock-client exports were removed. +const removedSourceMockClient = SourceTesting.createMockHatchetClient +// @ts-expect-error legacy mock-context exports were removed. +const removedSourceMockContext = SourceTesting.createMockContext +// @ts-expect-error built declarations expose only the modern in-memory Layer. +const removedBuiltMockLayer = BuiltTesting.TestHatchetLayer +// @ts-expect-error built declarations no longer expose mock-context helpers. +const removedBuiltTestTask = BuiltTesting.testTask + +void sourceLayer +void builtLayer +void sourceIdentity +void builtIdentity +void sourceTestingExportsAreExact +void builtTestingExportsAreExact +void removedSourceMockClient +void removedSourceMockContext +void removedBuiltMockLayer +void removedBuiltTestTask diff --git a/packages/hatchet/tests/unit/package-contract.test.ts b/packages/hatchet/tests/unit/package-contract.test.ts index 7839a80e..ffae9e87 100644 --- a/packages/hatchet/tests/unit/package-contract.test.ts +++ b/packages/hatchet/tests/unit/package-contract.test.ts @@ -11,8 +11,19 @@ const runNode = (source: string): string => encoding: "utf8", }) +const removedBuildPaths = [ + "../../dist/src/clients", + "../../dist/src/core", + "../../dist/src/logging", + "../../dist/src/schema", + "../../dist/src/testing/mock-client.js", + "../../dist/src/testing/mock-client.d.ts", + "../../dist/src/testing/mock-context.js", + "../../dist/src/testing/mock-context.d.ts", +].map((path) => new URL(path, import.meta.url)) + describe("published package contract", () => { - it("resolves the built testing subpath without exposing testing helpers from the root", () => { + it("exposes only the root in-memory Layer from the built testing subpath", () => { expect(existsSync(new URL("../../dist/src/testing/index.js", import.meta.url))).toBe(true) expect(existsSync(new URL("../../dist/src/testing/index.d.ts", import.meta.url))).toBe(true) @@ -20,14 +31,22 @@ describe("published package contract", () => { import * as root from "@effectify/hatchet" import * as testing from "@effectify/hatchet/testing" console.log(JSON.stringify({ - testingLayer: typeof testing.TestHatchetLayer, - rootHasTestingLayer: "TestHatchetLayer" in root, + exports: Object.keys(testing).sort(), + sameLayer: testing.layerInMemory === root.Hatchet.layerInMemory, + rootHasTestingLayer: "layerInMemory" in root, })) `) expect(JSON.parse(output)).toEqual({ - testingLayer: "object", + exports: ["layerInMemory"], + sameLayer: true, rootHasTestingLayer: false, }) }) + + it("omits legacy trees and testing mocks from the built package", () => { + for (const removedPath of removedBuildPaths) { + expect(existsSync(removedPath), removedPath.pathname).toBe(false) + } + }) }) diff --git a/packages/hatchet/tests/unit/public-api-source-contract.test.ts b/packages/hatchet/tests/unit/public-api-source-contract.test.ts index ebb727dd..05e4382c 100644 --- a/packages/hatchet/tests/unit/public-api-source-contract.test.ts +++ b/packages/hatchet/tests/unit/public-api-source-contract.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs" +import { existsSync, readFileSync } from "node:fs" import { describe, expect, it } from "vitest" const publicSourceFiles = [ @@ -10,6 +10,19 @@ const publicSourceFiles = [ "../../src/Model.ts", ].map((path) => new URL(path, import.meta.url)) const exampleSourceFile = new URL("../../scripts/test-workflow.ts", import.meta.url) +const removedLegacySourcePaths = [ + "../../src/clients", + "../../src/core", + "../../src/logging", + "../../src/schema", + "../../src/testing/mock-client.ts", + "../../src/testing/mock-context.ts", +].map((path) => new URL(path, import.meta.url)) +const retainedModernSourcePaths = [ + "../../src/Hatchet.ts", + "../../src/internal/live.ts", + "../../src/testing/index.ts", +].map((path) => new URL(path, import.meta.url)) const forbiddenManualLifecycleSymbols = [ "RegisteredTask", @@ -20,6 +33,15 @@ const forbiddenManualLifecycleSymbols = [ ] as const describe("public API source contract", () => { + it("removes the legacy graph while retaining the modern live and testing entry points", () => { + for (const removedPath of removedLegacySourcePaths) { + expect(existsSync(removedPath), removedPath.pathname).toBe(false) + } + for (const retainedPath of retainedModernSourcePaths) { + expect(existsSync(retainedPath), retainedPath.pathname).toBe(true) + } + }) + it("keeps manual worker lifecycle symbols out of public package modules", () => { for (const sourceFile of publicSourceFiles) { const source = readFileSync(sourceFile, "utf8") diff --git a/packages/node/better-auth/src/lib/handler.ts b/packages/node/better-auth/src/lib/handler.ts index 8eac93f4..b9dbd9de 100644 --- a/packages/node/better-auth/src/lib/handler.ts +++ b/packages/node/better-auth/src/lib/handler.ts @@ -8,21 +8,33 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest" import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest" -const TRAILING_SLASH_REGEX = /\/+$/ -const PROTOCOL_REGEX = /(https?:\/\/)+/ +type BetterAuthHandler = + | Auth["handler"] + | { + readonly handler: Auth["handler"] + readonly options?: Pick + } + +const canonicalOrigin = (value: string): string | undefined => { + if (!URL.canParse(value)) return undefined + + const url = new URL(value) + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username !== "" || + url.password !== "" || + url.hostname.includes("*") + ) { + return undefined + } + + return url.origin +} export const toEffectHandler: ( - auth: - | { - handler: Auth["handler"] - } - | Auth["handler"], -) => Effect.Effect< - HttpServerResponse.HttpServerResponse, - BetterAuthApiError | ConfigError, - HttpServerRequest.HttpServerRequest -> = (auth) => - Effect.gen(function*() { + auth: BetterAuthHandler, +) => Effect.Effect = (auth) => + Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest const nodeRequest = NodeHttpServerRequest.toIncomingMessage(request) const nodeResponse = NodeHttpServerRequest.toServerResponse(request) @@ -30,28 +42,23 @@ export const toEffectHandler: ( // Debug: log the configured app URL so we can diagnose Config errors yield* Effect.log(`toEffectHandler: BETTER_AUTH_URL=${String(appUrl)}`) - const normalizeUrl = (url: URL) => - url - .toString() - .replace(TRAILING_SLASH_REGEX, "") - .replace(PROTOCOL_REGEX, "http://") + const allowedOrigins = new Set([appUrl.origin]) + const trustedOrigins = typeof auth === "function" ? undefined : auth.options?.trustedOrigins + if (Array.isArray(trustedOrigins)) { + for (const trustedOrigin of trustedOrigins) { + if (typeof trustedOrigin !== "string") continue + const origin = canonicalOrigin(trustedOrigin) + if (origin !== undefined) allowedOrigins.add(origin) + } + } - const allowedOrigins = [normalizeUrl(appUrl)] - const origin = nodeRequest.headers.origin ? normalizeUrl(appUrl) : "" + const requestOrigin = nodeRequest.headers.origin + const origin = requestOrigin === undefined ? undefined : canonicalOrigin(requestOrigin) - if (allowedOrigins.includes(origin)) { - nodeResponse.setHeader( - "Access-Control-Allow-Origin", - nodeRequest.headers.origin || "", - ) - nodeResponse.setHeader( - "Access-Control-Allow-Methods", - "GET, POST, PUT, DELETE, OPTIONS", - ) - nodeResponse.setHeader( - "Access-Control-Allow-Headers", - "Content-Type, Authorization", - ) + if (origin !== undefined && allowedOrigins.has(origin)) { + nodeResponse.setHeader("Access-Control-Allow-Origin", origin) + nodeResponse.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + nodeResponse.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization") nodeResponse.setHeader("Access-Control-Max-Age", "600") nodeResponse.setHeader("Access-Control-Allow-Credentials", "true") } @@ -66,68 +73,49 @@ export const toEffectHandler: ( // Log incoming request for debugging const authHeader = nodeRequest.headers.authorization yield* Effect.log( - `toEffectHandler: incoming ${nodeRequest.method} ${ - String( - nodeRequest.url, - ) - } headers: cookie=${nodeRequest.headers.cookie?.substring(0, 50) || "none"}, auth=${ - authHeader?.substring(0, 30) || "none" - }`, + `toEffectHandler: incoming ${nodeRequest.method} ${String(nodeRequest.url)} headers: cookie=${ + nodeRequest.headers.cookie ? "present" : "none" + }, auth=${authHeader ? "present" : "none"}`, ) // If no cookie but has Authorization header (bearer token), set it as cookie for better-auth if (!nodeRequest.headers.cookie && authHeader?.startsWith("Bearer ")) { const token = authHeader.slice(7) // Remove "Bearer " prefix nodeRequest.headers.cookie = `better-auth.session_token=${token}` - yield* Effect.log( - `toEffectHandler: using token from Authorization header as cookie`, - ) + yield* Effect.log(`toEffectHandler: using token from Authorization header as cookie`) } - try { - yield* Effect.tryPromise({ - try: () => - "handler" in auth - ? toNodeHandler(auth.handler)(nodeRequest, nodeResponse) - : toNodeHandler(auth)(nodeRequest, nodeResponse), - catch: (cause) => new BetterAuthApiError({ cause }), - }) - - // Log the response status after the handler completes - yield* Effect.log( - `toEffectHandler: completed ${nodeRequest.method} ${ - String( - nodeRequest.url, - ) - } -> ${nodeResponse.statusCode}`, - ) - } catch (err) { - // Ensure we log errors from the underlying handler for debugging - yield* Effect.log( - `toEffectHandler: error handling ${nodeRequest.method} ${ - String( - nodeRequest.url, + const handler = typeof auth === "function" ? auth : auth.handler + return yield* Effect.tryPromise({ + try: () => toNodeHandler(handler)(nodeRequest, nodeResponse), + catch: (cause) => new BetterAuthApiError({ cause }), + }).pipe( + Effect.tap(() => + Effect.log( + `toEffectHandler: completed ${nodeRequest.method} ${String(nodeRequest.url)} -> ${nodeResponse.statusCode}`, + ), + ), + Effect.map(() => + HttpServerResponse.empty({ + status: nodeResponse.writableEnded ? nodeResponse.statusCode : 499, + }), + ), + Effect.catchTag("BetterAuthApiError", (error) => + Effect.gen(function* () { + const errorMessage = `${String(error)}: ${String(error.cause)}` + yield* Effect.log( + `toEffectHandler: error handling ${nodeRequest.method} ${String(nodeRequest.url)}: ${errorMessage}`, ) - }: ${String(err)}`, - ) - - try { - // Try to return the error to the client as JSON to make debugging easier - nodeResponse.statusCode = 500 - nodeResponse.setHeader("Content-Type", "application/json") - const payload = JSON.stringify({ error: String(err) }) - nodeResponse.end(payload) - } catch (writeErr) { - // If writing the error response fails, log that too - yield* Effect.log( - `toEffectHandler: failed to write error response: ${String(writeErr)}`, - ) - } - return HttpServerResponse.empty({ status: 500 }) - } + if (nodeResponse.headersSent || nodeResponse.writableEnded) { + if (!nodeResponse.writableEnded) { + yield* Effect.sync(() => nodeResponse.end()) + } + return HttpServerResponse.empty({ status: nodeResponse.statusCode }) + } - return HttpServerResponse.empty({ - status: nodeResponse.writableFinished ? nodeResponse.statusCode : 499, - }) + return HttpServerResponse.jsonUnsafe({ error: "Internal Server Error" }, { status: 500 }) + }), + ), + ) }) diff --git a/packages/node/better-auth/test/handler.test.ts b/packages/node/better-auth/test/handler.test.ts new file mode 100644 index 00000000..e500239f --- /dev/null +++ b/packages/node/better-auth/test/handler.test.ts @@ -0,0 +1,312 @@ +import { describe, it } from "@effect/vitest" +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer" +import { betterAuth, type Auth } from "better-auth" +import { ServerResponse } from "node:http" +import * as ConfigProvider from "effect/ConfigProvider" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Logger from "effect/Logger" +import * as Ref from "effect/Ref" +import * as HttpClient from "effect/unstable/http/HttpClient" +import * as HttpRouter from "effect/unstable/http/HttpRouter" +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" +import { expect } from "vitest" +import { toEffectHandler } from "../src/lib/handler.js" + +const BETTER_AUTH_URL = "http://localhost:3000" +const REJECTION_MESSAGE = "wrapped auth handler rejected" + +const captureLogs = () => { + const logs: Array = [] + const logger = Logger.make(({ message }) => { + const entry = Array.isArray(message) && message.length === 1 ? message[0] : message + logs.push(String(entry)) + }) + return { logger, logs } +} + +const serverLayers = (betterAuthUrl = BETTER_AUTH_URL) => [ + NodeHttpServer.layerTest, + ConfigProvider.layer(ConfigProvider.fromUnknown({ BETTER_AUTH_URL: betterAuthUrl })), +] + +describe("toEffectHandler", () => { + it.effect("recovers a rejected Better Auth Node handler with a logged JSON 500 response", () => { + const { logger, logs } = captureLogs() + const authHandler: Auth["handler"] = async () => { + throw new Error(REJECTION_MESSAGE) + } + + return Effect.gen(function* () { + const outcome = yield* Ref.make<"pending" | "failure" | "success">("pending") + const app = Effect.result(toEffectHandler(authHandler)).pipe( + Effect.flatMap((result) => { + if (result._tag === "Failure") { + return Ref.set(outcome, "failure").pipe( + Effect.as(HttpServerResponse.text("handler effect failed", { status: 599 })), + ) + } + return Ref.set(outcome, "success").pipe(Effect.as(result.success)) + }), + ) + + yield* HttpRouter.add("GET", "/", app).pipe(HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get("/") + const body = yield* response.text + + expect(yield* Ref.get(outcome)).toBe("success") + expect(response.status).toBe(500) + expect(response.headers["content-type"]).toContain("application/json") + expect(body).toBe('{"error":"Internal Server Error"}') + expect(body).not.toContain("BetterAuthApiError") + expect(body).not.toContain(REJECTION_MESSAGE) + expect( + logs.some( + (entry) => + entry.includes("toEffectHandler: error handling GET /:") && + entry.includes("BetterAuthApiError") && + entry.includes(REJECTION_MESSAGE), + ), + ).toBe(true) + }).pipe( + Effect.provide([NodeHttpServer.layerTest, ConfigProvider.layer(ConfigProvider.fromUnknown({ BETTER_AUTH_URL }))]), + Effect.provideService(Logger.CurrentLoggers, new Set([logger])), + ) + }) + + it.effect("allows the configured Better Auth origin without rewriting its scheme", () => { + const authHandler: Auth["handler"] = async () => new Response(null, { status: 204 }) + + return Effect.gen(function* () { + yield* HttpRouter.add("GET", "/", toEffectHandler(authHandler)).pipe(HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get("/", { headers: { Origin: BETTER_AUTH_URL } }) + + expect(response.headers["access-control-allow-origin"]).toBe(BETTER_AUTH_URL) + expect(response.headers["access-control-allow-credentials"]).toBe("true") + }).pipe(Effect.provide(serverLayers())) + }) + + it.effect("does not emit Access-Control-Allow-Origin for a foreign origin", () => { + const authHandler: Auth["handler"] = async () => new Response(null, { status: 204 }) + + return Effect.gen(function* () { + yield* HttpRouter.add("GET", "/", toEffectHandler(authHandler)).pipe(HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get("/", { headers: { Origin: "https://foreign.example" } }) + + expect(response.headers["access-control-allow-origin"]).toBeUndefined() + }).pipe(Effect.provide(serverLayers())) + }) + + it.effect("does not treat HTTP and HTTPS origins as equivalent", () => { + const authHandler: Auth["handler"] = async () => new Response(null, { status: 204 }) + + return Effect.gen(function* () { + yield* HttpRouter.add("GET", "/", toEffectHandler(authHandler)).pipe(HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get("/", { headers: { Origin: "https://localhost:3000" } }) + + expect(response.headers["access-control-allow-origin"]).toBeUndefined() + }).pipe(Effect.provide(serverLayers())) + }) + + it.effect("rejects null and malformed origins", () => { + const authHandler: Auth["handler"] = async () => new Response(null, { status: 204 }) + + return Effect.gen(function* () { + yield* HttpRouter.add("GET", "/", toEffectHandler(authHandler)).pipe(HttpRouter.serve, Layer.build) + + for (const origin of ["null", "://malformed"]) { + const response = yield* HttpClient.get("/", { headers: { Origin: origin } }) + expect(response.headers["access-control-allow-origin"]).toBeUndefined() + } + }).pipe(Effect.provide(serverLayers())) + }) + + it.effect("allows static trusted origins from a full Better Auth object without wildcard matching", () => { + const auth = betterAuth({ + baseURL: BETTER_AUTH_URL, + secret: "test-secret-for-static-trusted-origin-coverage", + trustedOrigins: ["https://trusted.example", "https://*.wildcard.example"], + }) + + return Effect.gen(function* () { + yield* HttpRouter.add("GET", "/", toEffectHandler(auth)).pipe(HttpRouter.serve, Layer.build) + + const trustedResponse = yield* HttpClient.get("/", { headers: { Origin: "https://trusted.example" } }) + const wildcardResponse = yield* HttpClient.get("/", { headers: { Origin: "https://tenant.wildcard.example" } }) + + expect(trustedResponse.headers["access-control-allow-origin"]).toBe("https://trusted.example") + expect(wildcardResponse.headers["access-control-allow-origin"]).toBeUndefined() + }).pipe(Effect.provide(serverLayers())) + }) + + it.effect("does not execute dynamic trusted origin callbacks", () => { + let callbackInvoked = false + const authHandler: Auth["handler"] = async () => new Response(null, { status: 204 }) + const auth = { + handler: authHandler, + options: { + trustedOrigins: () => { + callbackInvoked = true + return ["https://dynamic.example"] + }, + }, + } + + return Effect.gen(function* () { + yield* HttpRouter.add("GET", "/", toEffectHandler(auth)).pipe(HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get("/", { headers: { Origin: "https://dynamic.example" } }) + + expect(callbackInvoked).toBe(false) + expect(response.headers["access-control-allow-origin"]).toBeUndefined() + }).pipe(Effect.provide(serverLayers())) + }) + + it.effect("logs cookie and Authorization credentials only as presence markers", () => { + const { logger, logs } = captureLogs() + const cookieCredential = "cookie-secret-value" + const authorizationCredential = "authorization-secret-value" + const authHandler: Auth["handler"] = async () => new Response(null, { status: 204 }) + + return Effect.gen(function* () { + yield* HttpRouter.add("GET", "/", toEffectHandler(authHandler)).pipe(HttpRouter.serve, Layer.build) + + yield* HttpClient.get("/", { + headers: { + Cookie: `session=${cookieCredential}`, + Authorization: `Bearer ${authorizationCredential}`, + }, + }) + + const output = logs.join("\n") + expect(output).toContain("headers: cookie=present, auth=present") + expect(output).not.toContain(cookieCredential) + expect(output).not.toContain(authorizationCredential) + }).pipe(Effect.provide(serverLayers()), Effect.provideService(Logger.CurrentLoggers, new Set([logger]))) + }) + + it.effect("ends a committed Node response when the wrapped handler rejects", () => { + let committedResponse: ServerResponse | undefined + let postCommitHeaderWrites = 0 + const originalSetHeader = ServerResponse.prototype.setHeader + const originalWriteHead = ServerResponse.prototype.writeHead + const authHandler: Auth["handler"] = async () => + new Response(null, { + status: 202, + headers: { + "Content-Type": "text/plain", + "X-Effectify-Commit-Then-Reject": "true", + }, + }) + + return Effect.gen(function* () { + yield* Effect.sync(() => { + ServerResponse.prototype.setHeader = function (name, value) { + if (this === committedResponse && this.headersSent) { + postCommitHeaderWrites += 1 + } + return originalSetHeader.call(this, name, value) + } + ServerResponse.prototype.writeHead = function (this: ServerResponse, ...args: ReadonlyArray) { + if (this.getHeader("x-effectify-commit-then-reject") === "true") { + // oxlint-disable-next-line typescript/no-this-alias -- Capture the exact committed response for recovery assertions. + committedResponse = this + Reflect.apply(originalWriteHead, this, args) + throw new Error(REJECTION_MESSAGE) + } + return Reflect.apply(originalWriteHead, this, args) as ServerResponse + } as ServerResponse["writeHead"] + }) + + const outcome = yield* Ref.make<"pending" | "failure" | "success">("pending") + const app = Effect.result(toEffectHandler(authHandler)).pipe( + Effect.flatMap((result) => { + if (result._tag === "Failure") { + return Ref.set(outcome, "failure").pipe( + Effect.as(HttpServerResponse.text("handler effect failed", { status: 599 })), + ) + } + return Ref.set(outcome, "success").pipe(Effect.as(result.success)) + }), + ) + + yield* HttpRouter.add("GET", "/", app).pipe(HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get("/") + const body = yield* response.text + + expect(yield* Ref.get(outcome)).toBe("success") + expect(committedResponse?.headersSent).toBe(true) + expect(committedResponse?.writableEnded).toBe(true) + expect(postCommitHeaderWrites).toBe(0) + expect(response.status).toBe(202) + expect(response.headers["content-type"]).toContain("text/plain") + expect(body).toBe("") + }).pipe( + Effect.provide([NodeHttpServer.layerTest, ConfigProvider.layer(ConfigProvider.fromUnknown({ BETTER_AUTH_URL }))]), + Effect.ensuring( + Effect.sync(() => { + ServerResponse.prototype.setHeader = originalSetHeader + ServerResponse.prototype.writeHead = originalWriteHead + }), + ), + ) + }) + + it.effect("returns the delegated response status after the Node response has logically ended", () => { + const authHandler: Auth["handler"] = async () => + new Response("authenticated", { + status: 201, + headers: { "Content-Type": "text/plain" }, + }) + + return Effect.gen(function* () { + const returnedStatus = yield* Ref.make(undefined) + const app = toEffectHandler(authHandler).pipe(Effect.tap((response) => Ref.set(returnedStatus, response.status))) + yield* HttpRouter.add("GET", "/", app).pipe(HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get("/") + + expect(yield* Ref.get(returnedStatus)).toBe(201) + expect(response.status).toBe(201) + expect(yield* response.text).toBe("authenticated") + }).pipe(Effect.provide(serverLayers())) + }) + + it.effect("preserves invalid BETTER_AUTH_URL failures in the ConfigError channel", () => { + let handlerInvoked = false + const authHandler: Auth["handler"] = async () => { + handlerInvoked = true + return new Response() + } + + return Effect.gen(function* () { + const failureTag = yield* Ref.make(undefined) + const app = Effect.result(toEffectHandler(authHandler)).pipe( + Effect.flatMap((result) => { + if (result._tag === "Failure") { + return Ref.set(failureTag, result.failure._tag).pipe(Effect.as(HttpServerResponse.empty({ status: 598 }))) + } + return Effect.succeed(result.success) + }), + ) + + yield* HttpRouter.add("GET", "/", app).pipe(HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get("/") + + expect(response.status).toBe(598) + expect(yield* Ref.get(failureTag)).toBe("ConfigError") + expect(handlerInvoked).toBe(false) + }).pipe( + Effect.provide([ + NodeHttpServer.layerTest, + ConfigProvider.layer(ConfigProvider.fromUnknown({ BETTER_AUTH_URL: "not a URL" })), + ]), + ) + }) +}) diff --git a/packages/prisma/src/services/generator-service.ts b/packages/prisma/src/services/generator-service.ts index f2d179c9..1365ad1b 100644 --- a/packages/prisma/src/services/generator-service.ts +++ b/packages/prisma/src/services/generator-service.ts @@ -11,10 +11,10 @@ import { GenerateSchemnaService } from "../schema-generator/index.js" import { Data } from "effect" class GeneratorError extends Data.TaggedError("GeneratorError")<{ - message: string + details: string }> { override get message(): string { - return `Generator error: ${this.message}` + return `Generator error: ${this.details}` } } @@ -24,7 +24,7 @@ export class GeneratorService extends Context.Service< readonly generate: Effect.Effect } >()("GeneratorService", { - make: Effect.gen(function*() { + make: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const renderService = yield* RenderService @@ -32,9 +32,7 @@ export class GeneratorService extends Context.Service< const { render } = renderService const { format } = formatterService - const parseErrorImportPath = ( - errorImportPath: string | undefined, - ): { path: string; className: string } | null => { + const parseErrorImportPath = (errorImportPath: string | undefined): { path: string; className: string } | null => { if (!errorImportPath) { return null } @@ -58,26 +56,16 @@ export class GeneratorService extends Context.Service< return `${filePath}.${extension}` } - const getClientImportPath = ( - config: GeneratorOptions["generator"]["config"], - ) => + const getClientImportPath = (config: GeneratorOptions["generator"]["config"]) => Array.isArray(config.clientImportPath) ? config.clientImportPath[0] - : config.clientImportPath ?? "@prisma/client" + : (config.clientImportPath ?? "@prisma/client") - const getErrorImportPath = ( - config: GeneratorOptions["generator"]["config"], - ) => - Array.isArray(config.errorImportPath) - ? config.errorImportPath[0] - : config.errorImportPath + const getErrorImportPath = (config: GeneratorOptions["generator"]["config"]) => + Array.isArray(config.errorImportPath) ? config.errorImportPath[0] : config.errorImportPath - const getImportFileExtension = ( - config: GeneratorOptions["generator"]["config"], - ) => - Array.isArray(config.importFileExtension) - ? config.importFileExtension[0] - : config.importFileExtension ?? "" + const getImportFileExtension = (config: GeneratorOptions["generator"]["config"]) => + Array.isArray(config.importFileExtension) ? config.importFileExtension[0] : (config.importFileExtension ?? "") const getCustomError = ( config: GeneratorOptions["generator"]["config"], @@ -94,23 +82,15 @@ export class GeneratorService extends Context.Service< if (outputDir) { const absoluteErrorPath = path.resolve(schemaDir, customError.path) const relativeToOutput = path.relative(outputDir, absoluteErrorPath) - const normalizedPath = relativeToOutput.startsWith(".") - ? relativeToOutput - : `./${relativeToOutput}` - const pathWithExtension = addExtension( - normalizedPath, - importFileExtension, - ) + const normalizedPath = relativeToOutput.startsWith(".") ? relativeToOutput : `./${relativeToOutput}` + const pathWithExtension = addExtension(normalizedPath, importFileExtension) customError = { ...customError, path: pathWithExtension } } } return customError } - const getGeneratorConfig = ( - options: GeneratorOptions, - schemaDir: string, - ) => { + const getGeneratorConfig = (options: GeneratorOptions, schemaDir: string) => { const { config } = options.generator const clientImportPath = getClientImportPath(config) const customError = getCustomError(config, options, schemaDir) @@ -119,42 +99,30 @@ export class GeneratorService extends Context.Service< } const generatePrismaSchema = (outputDir: string) => - Effect.gen(function*() { + Effect.gen(function* () { const content = yield* render("prisma-schema", {}) const formatted = yield* format(content) - yield* fs.writeFileString( - path.join(outputDir, "prisma-schema.ts"), - formatted, - ) + yield* fs.writeFileString(path.join(outputDir, "prisma-schema.ts"), formatted) }) - const generatePrismaRepository = ( - outputDir: string, - clientImportPath: string, - ) => - Effect.gen(function*() { + const generatePrismaRepository = (outputDir: string, clientImportPath: string) => + Effect.gen(function* () { const content = yield* render("prisma-repository", { clientImportPath, }) const formatted = yield* format(content) - yield* fs.writeFileString( - path.join(outputDir, "prisma-repository.ts"), - formatted, - ) + yield* fs.writeFileString(path.join(outputDir, "prisma-repository.ts"), formatted) }) const generateModels = (outputDir: string, models: readonly DMMF.Model[]) => - Effect.gen(function*() { + Effect.gen(function* () { yield* fs.makeDirectory(path.join(outputDir, "models"), { recursive: true, }) for (const model of models) { const content = yield* render("model", { model }) const formatted = yield* format(content) - yield* fs.writeFileString( - path.join(outputDir, "models", `${model.name}.ts`), - formatted, - ) + yield* fs.writeFileString(path.join(outputDir, "models", `${model.name}.ts`), formatted) } }) @@ -164,16 +132,12 @@ export class GeneratorService extends Context.Service< clientImportPath: string, customError: { path: string; className: string } | null, ) => - Effect.gen(function*() { + Effect.gen(function* () { const errorType = customError ? customError.className : "PrismaError" const rawSqlOperations = yield* render("prisma-raw-sql", { errorType }) - const modelExports = models - .map((m) => `export * from "./models/${m.name}.js"`) - .join("\n") + const modelExports = models.map((m) => `export * from "./models/${m.name}.js"`).join("\n") - const templateName = customError - ? "index-custom-error" - : "index-default" + const templateName = customError ? "index-custom-error" : "index-default" const content = yield* render(templateName, { clientImportPath, customError, @@ -187,20 +151,17 @@ export class GeneratorService extends Context.Service< const generateSchema = yield* GenerateSchemnaService - const generate = Effect.gen(function*() { + const generate = Effect.gen(function* () { const options = yield* GeneratorContext const models = options.dmmf.datamodel.models const outputDir = options.generator.output?.value const schemaDir = path.dirname(options.schemaPath) if (!outputDir) { - return yield* new GeneratorError({ message: "No output directory specified" }) + return yield* new GeneratorError({ details: "No output directory specified" }) } - const { clientImportPath, customError } = getGeneratorConfig( - options, - schemaDir, - ) + const { clientImportPath, customError } = getGeneratorConfig(options, schemaDir) yield* fs.makeDirectory(outputDir, { recursive: true }) @@ -214,7 +175,8 @@ export class GeneratorService extends Context.Service< // Effect.provideService(FormatterService, formatterService), // ) - yield* generateSchema.generate(options.dmmf, schemasDir) + yield* generateSchema + .generate(options.dmmf, schemasDir) .pipe( Effect.provideService(RenderService, renderService), Effect.provideService(FormatterService, formatterService), diff --git a/packages/prisma/test/effect-beta57-prisma-generator.test.ts b/packages/prisma/test/effect-beta57-prisma-generator.test.ts index 39b3ce17..d4c09fc0 100644 --- a/packages/prisma/test/effect-beta57-prisma-generator.test.ts +++ b/packages/prisma/test/effect-beta57-prisma-generator.test.ts @@ -5,7 +5,9 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" import { spawn } from "node:child_process" +import * as Cause from "effect/Cause" import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" import * as Layer from "effect/Layer" import { afterEach, describe, expect, it } from "vitest" @@ -22,11 +24,7 @@ const makeTempDir = async () => { } afterEach(async () => { - await Promise.all( - createdDirs - .splice(0) - .map((dir) => rm(dir, { force: true, recursive: true })), - ) + await Promise.all(createdDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))) }) const baseSchema = ` @@ -68,20 +66,21 @@ const makeGeneratorOptions = async ( } as unknown as GeneratorOptions } -const generatorLayer = Layer.mergeAll( - GeneratorService.layer, - GenerateSchemnaService.layer, -).pipe(Layer.provideMerge(NodeServices.layer)) - -const runGenerator = (options: GeneratorOptions) => - Effect.runPromise( - Effect.service(GeneratorService).pipe( - Effect.flatMap(({ generate }) => generate), - Effect.provideService(GeneratorContext, options), - Effect.provide(generatorLayer), - ), +const generatorLayer = Layer.mergeAll(GeneratorService.layer, GenerateSchemnaService.layer).pipe( + Layer.provideMerge(NodeServices.layer), +) + +const generatorEffect = (options: GeneratorOptions) => + Effect.service(GeneratorService).pipe( + Effect.flatMap(({ generate }) => generate), + Effect.provideService(GeneratorContext, options), + Effect.provide(generatorLayer), ) +const runGenerator = (options: GeneratorOptions) => Effect.runPromise(generatorEffect(options)) + +const runGeneratorExit = (options: GeneratorOptions) => Effect.runPromiseExit(generatorEffect(options)) + const readGeneratedIndex = async (outputDir: string) => readFile(path.join(outputDir, "index.ts"), "utf8") const expectContextBasedRuntime = (source: string) => { @@ -105,15 +104,35 @@ const runPnpm = (cwd: string, args: Array) => resolve() return } - reject( - new Error( - stderr || `pnpm ${args.join(" ")} failed with exit code ${code}`, - ), - ) + reject(new Error(stderr || `pnpm ${args.join(" ")} failed with exit code ${code}`)) }) }) describe("beta57 prisma generator migration", () => { + it("reports a missing output directory as a prefixed GeneratorError defect", async () => { + const outputDir = path.join(await makeTempDir(), "generated", "effect") + const options = await makeGeneratorOptions(outputDir) + const exit = await runGeneratorExit({ + ...options, + generator: { + ...options.generator, + output: null, + }, + }) + + expect(Exit.isFailure(exit)).toBe(true) + if (!Exit.isFailure(exit)) { + throw new Error("expected generator failure") + } + + expect(exit.cause.reasons).toHaveLength(1) + const defects = exit.cause.reasons.filter(Cause.isDieReason).map((reason) => reason.defect) + expect(defects).toHaveLength(1) + expect(defects[0]).toHaveProperty("_tag", "GeneratorError") + expect(defects[0]).toHaveProperty("message", "Generator error: No output directory specified") + expect(defects[0]).toHaveProperty("details", "No output directory specified") + }) + it("generates the default runtime with Context services", async () => { const outputDir = path.join(await makeTempDir(), "generated", "effect") const options = await makeGeneratorOptions(outputDir) @@ -124,9 +143,7 @@ describe("beta57 prisma generator migration", () => { expectContextBasedRuntime(indexSource) expect(indexSource).toContain("export class PrismaClient") - expect(indexSource).toContain( - "export class Prisma extends Context.Service()", - ) + expect(indexSource).toContain("export class Prisma extends Context.Service()") }) it("generates the custom-error runtime with Context services", async () => { @@ -140,31 +157,14 @@ describe("beta57 prisma generator migration", () => { const indexSource = await readGeneratedIndex(outputDir) expectContextBasedRuntime(indexSource) - expect(indexSource).toContain( - 'import { AppPrismaError, mapPrismaError } from "../errors/prisma-error.js"', - ) + expect(indexSource).toContain('import { AppPrismaError, mapPrismaError } from "../errors/prisma-error.js"') }) it("regenerates the react-router example runtime without the dist CLI build", async () => { - const appDir = path.resolve( - import.meta.dirname, - "../../../apps/react-router-example", - ) - const generatedIndexPath = path.join( - appDir, - "prisma", - "generated", - "effect", - "index.ts", - ) - - await runPnpm(appDir, [ - "exec", - "prisma", - "generate", - "--schema", - "prisma/schema.prisma", - ]) + const appDir = path.resolve(import.meta.dirname, "../../../apps/react-router-example") + const generatedIndexPath = path.join(appDir, "prisma", "generated", "effect", "index.ts") + + await runPnpm(appDir, ["exec", "prisma", "generate", "--schema", "prisma/schema.prisma"]) const indexSource = await readFile(generatedIndexPath, "utf8") diff --git a/packages/react/query/package.json b/packages/react/query/package.json index cffe466a..63c01018 100644 --- a/packages/react/query/package.json +++ b/packages/react/query/package.json @@ -34,10 +34,12 @@ "devDependencies": { "typescript": "catalog:", "@types/react": "catalog:", - "effect": "catalog:" + "@types/react-dom": "catalog:", + "effect": "catalog:", + "react-dom": "catalog:" }, "optionalDependencies": {}, "peerDependencies": { - "effect": "^3.19.16 || ^4.0.0-beta" + "effect": "^4.0.0-beta" } } diff --git a/packages/react/query/project.json b/packages/react/query/project.json index 35397b8b..e37e3b00 100644 --- a/packages/react/query/project.json +++ b/packages/react/query/project.json @@ -5,6 +5,20 @@ "projectType": "library", "tags": ["react"], "targets": { + "test": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec vitest run --config vitest.config.ts", + "cwd": "packages/react/query" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "tsc --project tsconfig.spec.json --noEmit", + "cwd": "packages/react/query" + } + }, "build": { "executor": "@nx/js:tsc", "outputs": ["{options.outputPath}"], diff --git a/packages/react/query/src/lib/internal/make-use-rx-subsciption-ref.ts b/packages/react/query/src/lib/internal/make-use-rx-subsciption-ref.ts index 90067ed8..49132798 100644 --- a/packages/react/query/src/lib/internal/make-use-rx-subsciption-ref.ts +++ b/packages/react/query/src/lib/internal/make-use-rx-subsciption-ref.ts @@ -1,43 +1,20 @@ -import { type Context, useContext } from "react" import type * as ManagedRuntime from "effect/ManagedRuntime" -import type { Subscribable, SubscriptionOptions } from "../types.js" -import type * as Effect from "effect/Effect" +import * as SubscriptionRef from "effect/SubscriptionRef" +import { type Context, useCallback, useMemo, useRef } from "react" +import type { SubscriptionOptions } from "../types.js" +import { makeUseRxSubscribe } from "./make-use-rx-subscribe.js" -/** - * ⚠️ TEMPORARILY DISABLED - Effect v4 Migration - * - * This hook is temporarily disabled due to significant API changes in Effect v4: - * - SubscriptionRef.SubscriptionRefTypeId was removed - * - Stream APIs reorganized under effect/unstable/* - * - Migration documentation is incomplete (see Effect-TS/effect-smol#1378) - * - * The core functionality (useEffectQuery, useEffectMutation) works with v4. - * This advanced subscription feature will be revisited when v4 documentation - * is complete or when the beta stabilizes. - * - * TODO: Re-enable after Effect v4 stable release and documentation update - * @deprecated Temporarily disabled during Effect v4 beta migration - */ -export const makeUseRxSubscriptionRef = - (RuntimeContext: Context | null>) => - ( - _subscribable: - | Subscribable - | Effect.Effect, never, R> - | Effect.Effect, - _onNext: (value: A) => void, - _opts?: SubscriptionOptions, - ): A => { - const runtime = useContext(RuntimeContext) - if (!runtime) { - throw new Error( - "Runtime context not found. Make sure to wrap your app with RuntimeProvider", - ) - } +export const makeUseRxSubscriptionRef = (RuntimeContext: Context | null>) => { + const useRxSubscribe = makeUseRxSubscribe(RuntimeContext) - throw new Error( - "useRxSubscriptionRef is temporarily disabled during Effect v4 beta migration. " + - "Please use useEffectQuery or useEffectMutation instead, or wait for v4 stable release. " + - "See: https://github.com/Effect-TS/effect-smol/issues/1378", - ) + return (ref: SubscriptionRef.SubscriptionRef, onNext: (value: A) => void, opts?: SubscriptionOptions): A => { + const changes = useMemo(() => SubscriptionRef.changes(ref), [ref]) + const skipInitial = opts?.skipInitial ?? true + const onNextRef = useRef(onNext) + onNextRef.current = onNext + const handleNext = useCallback((value: A) => onNextRef.current(value), []) + + const currentValue = SubscriptionRef.getUnsafe(ref) + return useRxSubscribe(changes, currentValue, handleNext, undefined, skipInitial) } +} diff --git a/packages/react/query/src/lib/internal/make-use-rx-subscribe.ts b/packages/react/query/src/lib/internal/make-use-rx-subscribe.ts index c598b5e5..a442a1d4 100644 --- a/packages/react/query/src/lib/internal/make-use-rx-subscribe.ts +++ b/packages/react/query/src/lib/internal/make-use-rx-subscribe.ts @@ -1,69 +1,51 @@ import * as Effect from "effect/Effect" -import * as Exit from "effect/Exit" -import * as Fiber from "effect/Fiber" import type * as ManagedRuntime from "effect/ManagedRuntime" import * as Stream from "effect/Stream" -import { type Context, useContext, useEffect, useRef, useState } from "react" +import { type Context, useContext, useEffect, useMemo, useState } from "react" -export const makeUseRxSubscribe = ( - RuntimeContext: Context | null>, -) => { +export const makeUseRxSubscribe = (RuntimeContext: Context | null>) => { return ( - stream: - | Stream.Stream - | Effect.Effect, E2, R>, + stream: Stream.Stream | Effect.Effect, E2, R>, initialValue: A, onNext: (value: A) => void, onError?: (error: E2) => void, + skipInitial = false, ) => { const runtime = useContext(RuntimeContext) if (!runtime) { - throw new Error( - "Runtime context not found. Make sure to wrap your app with RuntimeProvider", - ) + throw new Error("Runtime context not found. Make sure to wrap your app with RuntimeProvider") } - const [value, setValue] = useState(initialValue) - const fiberRef = useRef | null>(null) - - const finalStream = Effect.isEffect(stream) - ? Stream.unwrap(stream) - : stream + const [value, setValue] = useState(() => initialValue) + const finalStream = useMemo(() => (Effect.isEffect(stream) ? Stream.unwrap(stream) : stream), [stream]) useEffect(() => { + let isInitial = true const subscription = finalStream.pipe( Stream.tap((a) => Effect.sync(() => { setValue(a) + if (isInitial) { + isInitial = false + if (skipInitial) return + } onNext(a) - }) + }), ), Stream.catch((e: E2) => Stream.fromEffect( Effect.sync(() => { onError?.(e) - return }), - ) + ), ), Stream.runDrain, - Effect.forever, - Effect.forkDetach, ) - runtime.runCallback(subscription, { - onExit: (exit) => { - if (Exit.isSuccess(exit)) { - fiberRef.current = exit.value - } - }, - }) - + const cancel = runtime.runCallback(subscription) return () => { - if (fiberRef.current !== null) { - runtime.runCallback(Fiber.interrupt(fiberRef.current)) - } + cancel() } - }, [finalStream, runtime, onNext, onError]) + }, [finalStream, runtime, onNext, onError, skipInitial]) return value } diff --git a/packages/react/query/src/lib/types.ts b/packages/react/query/src/lib/types.ts index 285109d4..2bec4032 100644 --- a/packages/react/query/src/lib/types.ts +++ b/packages/react/query/src/lib/types.ts @@ -3,29 +3,14 @@ import type * as Effect from "effect/Effect" export type QueryKey = readonly [string, Record?] export type EffectfulError = { _tag: string } -export type Runner = () => ( - span: string, -) => (effect: Effect.Effect) => Promise -export type EffectfulMutationOptions< - TData, - TError extends EffectfulError, - TVariables, - R, -> = - & Omit< - UseMutationOptions, - | "mutationFn" - | "onSuccess" - | "onError" - | "onSettled" - | "onMutate" - | "retry" - | "retryDelay" - > - & { - mutationKey: QueryKey - mutationFn: (variables: TVariables) => Effect.Effect - } +export type Runner = () => (span: string) => (effect: Effect.Effect) => Promise +export type EffectfulMutationOptions = Omit< + UseMutationOptions, + "mutationFn" | "onSuccess" | "onError" | "onSettled" | "onMutate" | "retry" | "retryDelay" +> & { + mutationKey: QueryKey + mutationFn: (variables: TVariables) => Effect.Effect +} export type EffectfulQueryFunction< TData, @@ -33,34 +18,16 @@ export type EffectfulQueryFunction< TQueryKey extends QueryKey = QueryKey, R = never, TPageParam = never, -> = ( - context: QueryFunctionContext, -) => Effect.Effect - -export type EffectfulQueryOptions< - TData, - TError, - R, - TQueryKey extends QueryKey = QueryKey, - TPageParam = never, -> = - & Omit< - UseQueryOptions, - "queryKey" | "queryFn" | "retry" | "retryDelay" | "staleTime" | "gcTime" - > - & { - queryKey: TQueryKey - queryFn: - | EffectfulQueryFunction - | typeof skipToken - staleTime?: number - gcTime?: number - } +> = (context: QueryFunctionContext) => Effect.Effect -export interface Subscribable { - readonly changes: unknown - readonly get: () => A - readonly _errorType?: E +export type EffectfulQueryOptions = Omit< + UseQueryOptions, + "queryKey" | "queryFn" | "retry" | "retryDelay" | "staleTime" | "gcTime" +> & { + queryKey: TQueryKey + queryFn: EffectfulQueryFunction | typeof skipToken + staleTime?: number + gcTime?: number } export interface SubscriptionOptions { diff --git a/packages/react/query/tests/subscription-ref-contract.test.ts b/packages/react/query/tests/subscription-ref-contract.test.ts new file mode 100644 index 00000000..82e57c6c --- /dev/null +++ b/packages/react/query/tests/subscription-ref-contract.test.ts @@ -0,0 +1,125 @@ +import { QueryClient } from "@tanstack/react-query" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as ManagedRuntime from "effect/ManagedRuntime" +import * as SubscriptionRef from "effect/SubscriptionRef" +import { act, createContext, createElement, StrictMode } from "react" +import { createRoot } from "react-dom/client" +import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest" +import { makeUseRxSubscriptionRef } from "../src/lib/internal/make-use-rx-subsciption-ref.js" +import { tanstackQueryEffect } from "../src/index.js" + +const acquiredRef = Effect.runSync(SubscriptionRef.make(0)) +const queryEffect = tanstackQueryEffect({ + layer: Layer.empty, + queryClient: new QueryClient(), +}) + +const usePublicContract = () => { + const value = queryEffect.useRxSubscriptionRef(acquiredRef, () => {}) + expectTypeOf(value).toEqualTypeOf() + + // @ts-expect-error SubscriptionRef acquisition must happen before render. + queryEffect.useRxSubscriptionRef(SubscriptionRef.make(0), () => {}) + + // @ts-expect-error Arbitrary Effects are not subscribable refs. + queryEffect.useRxSubscriptionRef(Effect.succeed(acquiredRef), () => {}) +} +void usePublicContract + +const settle = () => new Promise((resolve) => setTimeout(resolve, 10)) + +beforeEach(() => { + Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true) +}) + +describe("React SubscriptionRef hook contract", () => { + it("suppresses each StrictMode replay by default and returns reactive state", async () => { + const subscriptionRef = Effect.runSync(SubscriptionRef.make(0)) + const runtime = ManagedRuntime.make(Layer.empty) + const RuntimeContext = createContext | null>(runtime) + const useRxSubscriptionRef = makeUseRxSubscriptionRef(RuntimeContext) + const onNext = vi.fn() + const observed: Array = [] + const root = createRoot(document.createElement("div")) + + const Probe = () => { + observed.push(useRxSubscriptionRef(subscriptionRef, onNext)) + return null + } + + try { + await act(async () => { + root.render(createElement(StrictMode, null, createElement(Probe))) + await settle() + }) + + expect(observed.at(-1)).toBe(0) + expect(onNext).not.toHaveBeenCalled() + + await act(async () => { + await runtime.runPromise(SubscriptionRef.set(subscriptionRef, 1)) + await vi.waitFor(() => expect(onNext).toHaveBeenCalledWith(1)) + }) + + expect(observed.at(-1)).toBe(1) + expect(onNext).toHaveBeenCalledTimes(1) + } finally { + await act(async () => root.unmount()) + await runtime.dispose() + } + }) + + it("forwards a StrictMode replay when skipInitial is false", async () => { + const subscriptionRef = Effect.runSync(SubscriptionRef.make(0)) + const runtime = ManagedRuntime.make(Layer.empty) + const RuntimeContext = createContext | null>(runtime) + const useRxSubscriptionRef = makeUseRxSubscriptionRef(RuntimeContext) + const onNext = vi.fn() + const root = createRoot(document.createElement("div")) + + const Probe = () => { + useRxSubscriptionRef(subscriptionRef, onNext, { skipInitial: false }) + return null + } + + try { + await act(async () => root.render(createElement(StrictMode, null, createElement(Probe)))) + await vi.waitFor(() => expect(onNext).toHaveBeenCalledWith(0)) + } finally { + await act(async () => root.unmount()) + await runtime.dispose() + } + }) + + it("cancels the subscription on immediate unmount", async () => { + const subscriptionRef = Effect.runSync(SubscriptionRef.make(0)) + const runtime = ManagedRuntime.make(Layer.empty) + const RuntimeContext = createContext | null>(runtime) + const useRxSubscriptionRef = makeUseRxSubscriptionRef(RuntimeContext) + const onNext = vi.fn() + const root = createRoot(document.createElement("div")) + let mounted = true + + const Probe = () => { + useRxSubscriptionRef(subscriptionRef, onNext, { skipInitial: false }) + return null + } + + try { + await act(async () => root.render(createElement(StrictMode, null, createElement(Probe)))) + await vi.waitFor(() => expect(onNext).toHaveBeenCalledWith(0)) + onNext.mockClear() + + await act(async () => root.unmount()) + mounted = false + await runtime.runPromise(SubscriptionRef.set(subscriptionRef, 1)) + await settle() + + expect(onNext).not.toHaveBeenCalled() + } finally { + if (mounted) await act(async () => root.unmount()) + await runtime.dispose() + } + }) +}) diff --git a/packages/react/query/tsconfig.spec.json b/packages/react/query/tsconfig.spec.json new file mode 100644 index 00000000..45c485ac --- /dev/null +++ b/packages/react/query/tsconfig.spec.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "lib": ["es2022", "dom", "dom.iterable"], + "types": ["vitest/globals", "node"] + }, + "include": ["tests/**/*.ts", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/react/query/vitest.config.ts b/packages/react/query/vitest.config.ts new file mode 100644 index 00000000..cc9493f6 --- /dev/null +++ b/packages/react/query/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + root: __dirname, + test: { + environment: "jsdom", + allowOnly: false, + include: ["tests/**/*.test.ts"], + }, +}) diff --git a/packages/react/router/src/index.ts b/packages/react/router/src/index.ts index e684a1e1..f9ad6448 100644 --- a/packages/react/router/src/index.ts +++ b/packages/react/router/src/index.ts @@ -1,5 +1,4 @@ export * from "./lib/context.js" -// TODO: Implement HttpApiHandler fot v4 -// export * as HttpApiHandler from "./lib/http-api-handler.js" +export * as HttpApiHandler from "./lib/http-api-handler.js" export * from "./lib/http-response.js" export * as Runtime from "./lib/runtime.js" diff --git a/packages/react/router/src/lib/http-api-handler.ts b/packages/react/router/src/lib/http-api-handler.ts index f332cc52..0ed190be 100644 --- a/packages/react/router/src/lib/http-api-handler.ts +++ b/packages/react/router/src/lib/http-api-handler.ts @@ -1,11 +1,74 @@ +import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" +import * as HttpRouter from "effect/unstable/http/HttpRouter" +import * as HttpServer from "effect/unstable/http/HttpServer" +import type * as HttpApi from "effect/unstable/httpapi/HttpApi" +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder" +import type * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup" +import * as HttpApiScalar from "effect/unstable/httpapi/HttpApiScalar" +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router" -export type HttpApiOptions = { - apiLive: any - scalar?: any +export type HttpApiOptions = { + readonly api: HttpApi.HttpApi + readonly apiLive: Layer.Layer, never, never> + readonly scalar?: HttpApiScalar.ScalarConfig } export type RoutePath = "/" | `/${string}/` -// Stub implementation that does nothing -export const make = (_options: HttpApiOptions & { pathPrefix?: RoutePath }) => () => Layer.empty +export type HttpApiHandler = { + (args: ActionFunctionArgs | LoaderFunctionArgs): Promise + readonly dispose: () => Promise +} + +const prefixRoutes = ( + routes: Layer.Layer, + pathPrefix: RoutePath, +): Layer.Layer => + Layer.unwrap( + Effect.map(HttpRouter.HttpRouter, (router) => + Layer.provide(routes, Layer.succeed(HttpRouter.HttpRouter, router.prefixed(pathPrefix))), + ), + ) + +export const make = ( + options: HttpApiOptions & { readonly pathPrefix?: RoutePath }, +): HttpApiHandler => { + const createWebHandler = (request: Request) => { + const ApiRoutes = HttpApiBuilder.layer(options.api).pipe( + Layer.provide(options.apiLive), + Layer.provide(HttpServer.layerServices), + ) + const ScalarRoutes = + options.scalar === undefined + ? Layer.empty + : HttpApiScalar.layer(options.api, { + path: options.pathPrefix === undefined ? "/api/docs" : "/docs", + scalar: { + ...options.scalar, + baseServerURL: options.scalar.baseServerURL ?? new URL(request.url).origin, + }, + }) + const Routes = Layer.merge(ApiRoutes, ScalarRoutes) + const AppRoutes = options.pathPrefix === undefined ? Routes : prefixRoutes(Routes, options.pathPrefix) + return HttpRouter.toWebHandler(AppRoutes) + } + + let webHandler: ReturnType | undefined + let disposal: Promise | undefined + let disposed = false + + const dispose = () => { + if (disposal !== undefined) return disposal + disposed = true + disposal = webHandler === undefined ? Promise.resolve() : webHandler.dispose() + return disposal + } + const handler = (args: ActionFunctionArgs | LoaderFunctionArgs) => { + if (disposed) return Promise.reject(new Error("HTTP API handler has been disposed")) + const live = (webHandler ??= createWebHandler(args.request)) + return live.handler(args.request) + } + + return Object.assign(handler, { dispose }) +} diff --git a/packages/react/router/tests/http-api-handler.test.ts b/packages/react/router/tests/http-api-handler.test.ts new file mode 100644 index 00000000..751a0bcb --- /dev/null +++ b/packages/react/router/tests/http-api-handler.test.ts @@ -0,0 +1,155 @@ +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Schema from "effect/Schema" +import * as HttpApi from "effect/unstable/httpapi/HttpApi" +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder" +import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint" +import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup" +import { RouterContextProvider } from "react-router" +import { describe, expect, it } from "vitest" +import { HttpApiHandler } from "../src/index.js" + +const TestApi = HttpApi.make("RouterTestApi").add( + HttpApiGroup.make("test") + .add(HttpApiEndpoint.get("getMessage", "/hello", { success: Schema.String })) + .add(HttpApiEndpoint.post("postMessage", "/submit", { success: Schema.String })), +) + +class AdapterProbe extends Context.Service< + AdapterProbe, + { + readonly getMessage: string + readonly postMessage: string + } +>()("@effectify/react-router/test/AdapterProbe") {} + +type Counters = { + acquired: number + finalized: number +} + +const makeApiLive = (counters: Counters = { acquired: 0, finalized: 0 }) => { + const ProbeLive = Layer.effect( + AdapterProbe, + Effect.gen(function* () { + counters.acquired += 1 + yield* Effect.addFinalizer(() => + Effect.sync(() => { + counters.finalized += 1 + }), + ) + return AdapterProbe.of({ + getMessage: "hello from loader", + postMessage: "hello from action", + }) + }), + ) + const HandlersLive = HttpApiBuilder.group(TestApi, "test", (handlers) => + handlers + .handle("getMessage", () => Effect.map(AdapterProbe, (probe) => probe.getMessage)) + .handle("postMessage", () => Effect.map(AdapterProbe, (probe) => probe.postMessage)), + ) + + return HandlersLive.pipe(Layer.provide(ProbeLive)) +} + +const routeArgs = (url: string, init?: RequestInit) => ({ + context: new RouterContextProvider(), + params: {}, + request: new Request(url, init), + url: new URL(url), + pattern: new URL(url).pathname, +}) + +const expectJson = async (response: Response, expected: unknown) => { + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual(expected) +} + +describe("React Router HTTP API handler", () => { + it("dispatches loader and action requests through the Effect HTTP API", async () => { + const adapter = HttpApiHandler.make({ + api: TestApi, + apiLive: makeApiLive(), + }) + + try { + await expectJson(await adapter(routeArgs("https://effectify.dev/hello")), "hello from loader") + await expectJson( + await adapter(routeArgs("https://effectify.dev/submit", { method: "POST" })), + "hello from action", + ) + } finally { + await adapter.dispose() + } + }) + + it("registers API and Scalar routes on a prefixed HttpRouter", async () => { + const adapter = HttpApiHandler.make({ + api: TestApi, + apiLive: makeApiLive(), + pathPrefix: "/service/", + scalar: { theme: "purple" }, + }) + + try { + await expectJson(await adapter(routeArgs("https://effectify.dev/service/hello")), "hello from loader") + expect((await adapter(routeArgs("https://effectify.dev/hello"))).status).toBe(404) + + const docs = await adapter(routeArgs("https://effectify.dev/service/docs")) + expect(docs.status).toBe(200) + const html = await docs.text() + expect(html).toContain('"baseServerURL":"https://effectify.dev"') + expect(html).toContain('"theme":"purple"') + expect((await adapter(routeArgs("https://effectify.dev/api/docs"))).status).toBe(404) + } finally { + await adapter.dispose() + } + }) + + it("mounts optional Scalar docs at the historical default path with v4 options", async () => { + const withoutScalar = HttpApiHandler.make({ + api: TestApi, + apiLive: makeApiLive(), + }) + const withScalar = HttpApiHandler.make({ + api: TestApi, + apiLive: makeApiLive(), + scalar: { + baseServerURL: "https://configured.example", + theme: "moon", + }, + }) + + try { + expect((await withoutScalar(routeArgs("https://effectify.dev/api/docs"))).status).toBe(404) + + const docs = await withScalar(routeArgs("https://effectify.dev/api/docs")) + expect(docs.status).toBe(200) + const html = await docs.text() + expect(html).toContain('"baseServerURL":"https://configured.example"') + expect(html).not.toContain('"baseServerURL":"https://effectify.dev"') + expect(html).toContain('"theme":"moon"') + } finally { + await Promise.all([withoutScalar.dispose(), withScalar.dispose()]) + } + }) + + it("reuses one web handler and disposes its finalizers exactly once", async () => { + const counters = { acquired: 0, finalized: 0 } + const adapter = HttpApiHandler.make({ + api: TestApi, + apiLive: makeApiLive(counters), + }) + + await adapter(routeArgs("https://effectify.dev/hello")) + await adapter(routeArgs("https://effectify.dev/hello")) + expect(counters).toEqual({ acquired: 1, finalized: 0 }) + + await Promise.all([adapter.dispose(), adapter.dispose()]) + await adapter.dispose() + expect(counters).toEqual({ acquired: 1, finalized: 1 }) + }) +}) diff --git a/packages/shared/domain/src/email.test.ts b/packages/shared/domain/src/email.test.ts new file mode 100644 index 00000000..2c319257 --- /dev/null +++ b/packages/shared/domain/src/email.test.ts @@ -0,0 +1,19 @@ +import * as Schema from "effect/Schema" +import { describe, expect, it } from "vitest" +import { Email } from "./email.js" + +describe("Email", () => { + it("constructs and decodes valid email addresses without normalizing them", () => { + const input = "Ada.Lovelace+chat@Example.COM" + + expect(Email.make(input)).toBe(input) + expect(Schema.decodeSync(Email)(input)).toBe(input) + }) + + it("rejects invalid values during construction and decoding", () => { + expect(() => Email.make("not-an-email")).toThrow() + expect(() => Email.make("a@b")).toThrow() + expect(() => Schema.decodeSync(Email)("not-an-email")).toThrow() + expect(() => Schema.decodeUnknownSync(Email)(42)).toThrow() + }) +}) diff --git a/packages/shared/domain/src/email.ts b/packages/shared/domain/src/email.ts index c8f88856..0dcb85bb 100644 --- a/packages/shared/domain/src/email.ts +++ b/packages/shared/domain/src/email.ts @@ -1,7 +1,15 @@ -// Stub for v4 compatibility -export type Email = string & { readonly __brand: unique symbol } +import * as Schema from "effect/Schema" +import { isValidEmail } from "./validators.js" -export const Email = { - make: (value: string): Email => value as Email, - isValid: (value: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value), -} +export const Email = Schema.String.check( + Schema.isMinLength(3), + Schema.makeFilter((value) => (isValidEmail(value) ? undefined : `${value} is not a valid email`)), +) + .pipe(Schema.brand("Email")) + .annotate({ + title: "Email", + description: "An email address", + format: "email", + }) + +export type Email = typeof Email.Type diff --git a/packages/solid/query/package.json b/packages/solid/query/package.json index 3cb21f02..86b5ee11 100644 --- a/packages/solid/query/package.json +++ b/packages/solid/query/package.json @@ -31,7 +31,7 @@ "peerDependencies": { "@tanstack/query-core": "^5.90.20", "@tanstack/solid-query": "^5.90.23", - "effect": "^3.19.16 || ^4.0.0-beta", + "effect": "^4.0.0-beta", "solid-js": "^1.9.11" }, "devDependencies": { diff --git a/packages/solid/query/project.json b/packages/solid/query/project.json index 5e9c35ca..19100353 100644 --- a/packages/solid/query/project.json +++ b/packages/solid/query/project.json @@ -5,6 +5,20 @@ "projectType": "library", "tags": ["solid"], "targets": { + "test": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec vitest run --config vitest.config.ts", + "cwd": "packages/solid/query" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "tsc --project tsconfig.spec.json --noEmit", + "cwd": "packages/solid/query" + } + }, "build": { "executor": "@nx/js:tsc", "outputs": ["{options.outputPath}"], diff --git a/packages/solid/query/src/lib/internal/make-use-rx-subsciption-ref.ts b/packages/solid/query/src/lib/internal/make-use-rx-subsciption-ref.ts index add8ceff..03ada5c9 100644 --- a/packages/solid/query/src/lib/internal/make-use-rx-subsciption-ref.ts +++ b/packages/solid/query/src/lib/internal/make-use-rx-subsciption-ref.ts @@ -1,45 +1,27 @@ -import { type Context, useContext } from "solid-js" import type * as ManagedRuntime from "effect/ManagedRuntime" -import type { Subscribable, SubscriptionOptions } from "../types.js" -import type * as Effect from "effect/Effect" +import * as SubscriptionRef from "effect/SubscriptionRef" +import { type Accessor, type Context } from "solid-js" +import type { SubscriptionOptions } from "../types.js" +import { makeUseRxSubscribe } from "./make-use-rx-subscribe.js" -/** - * ⚠️ TEMPORARILY DISABLED - Effect v4 Migration - * - * This hook is temporarily disabled due to significant API changes in Effect v4: - * - SubscriptionRef.SubscriptionRefTypeId was removed - * - Stream APIs reorganized under effect/unstable/* - * - Migration documentation is incomplete (see Effect-TS/effect-smol#1378) - * - * The core functionality (useEffectQuery, useEffectMutation) works with v4. - * This advanced subscription feature will be revisited when v4 documentation - * is complete or when the beta stabilizes. - * - * TODO: Re-enable after Effect v4 stable release and documentation update - * @deprecated Temporarily disabled during Effect v4 beta migration - */ -export const makeUseRxSubscriptionRef = - (RuntimeContext: Context | null>) => - ( - _subscribable: - | Subscribable - | Effect.Effect, never, R> - | Effect.Effect, - _onNext: (value: A) => void, - _opts?: SubscriptionOptions, - ): () => A => { - const runtime = useContext(RuntimeContext) - if (!runtime) { - throw new Error( - "Runtime context not found. Make sure to wrap your app with RuntimeProvider", - ) - } +export const makeUseRxSubscriptionRef = (RuntimeContext: Context | null>) => { + const useRxSubscribe = makeUseRxSubscribe(RuntimeContext) - return () => { - throw new Error( - "useRxSubscriptionRef is temporarily disabled during Effect v4 beta migration. " + - "Please use useEffectQuery or useEffectMutation instead, or wait for v4 stable release. " + - "See: https://github.com/Effect-TS/effect-smol/issues/1378", - ) - } + return ( + ref: SubscriptionRef.SubscriptionRef, + onNext: (value: A) => void, + opts?: SubscriptionOptions, + ): Accessor => { + const changes = SubscriptionRef.changes(ref) + const skipInitial = opts?.skipInitial ?? true + let isInitial = true + const currentValue = SubscriptionRef.getUnsafe(ref) + return useRxSubscribe(changes, currentValue, (nextValue) => { + if (isInitial) { + isInitial = false + if (skipInitial) return + } + onNext(nextValue) + }) } +} diff --git a/packages/solid/query/src/lib/internal/make-use-rx-subscribe.ts b/packages/solid/query/src/lib/internal/make-use-rx-subscribe.ts index bdbfcd10..c1e45a6f 100644 --- a/packages/solid/query/src/lib/internal/make-use-rx-subscribe.ts +++ b/packages/solid/query/src/lib/internal/make-use-rx-subscribe.ts @@ -1,72 +1,40 @@ import * as Effect from "effect/Effect" -import * as Exit from "effect/Exit" -import * as Fiber from "effect/Fiber" import type * as ManagedRuntime from "effect/ManagedRuntime" import * as Stream from "effect/Stream" import { type Context, createSignal, onCleanup, useContext } from "solid-js" -export const makeUseRxSubscribe = ( - RuntimeContext: Context | null>, -) => { +export const makeUseRxSubscribe = (RuntimeContext: Context | null>) => { return ( - stream: - | Stream.Stream - | Effect.Effect, E2, R>, + stream: Stream.Stream | Effect.Effect, E2, R>, initialValue: A, onNext: (value: A) => void, onError?: (error: E2) => void, ) => { const runtime = useContext(RuntimeContext) if (!runtime) { - throw new Error( - "Runtime context not found. Make sure to wrap your app with RuntimeProvider", - ) + throw new Error("Runtime context not found. Make sure to wrap your app with RuntimeProvider") } - const [value, setValue] = createSignal(initialValue) - const [fiberRef, setFiberRef] = createSignal< - Fiber.Fiber< - never, - never - > | null - >(null) - - const finalStream = Effect.isEffect(stream) - ? Stream.unwrap(stream) - : stream - + const [value, setValue] = createSignal(initialValue) + const finalStream = Effect.isEffect(stream) ? Stream.unwrap(stream) : stream const subscription = finalStream.pipe( Stream.tap((a) => Effect.sync(() => { setValue(() => a) onNext(a) - }) + }), ), Stream.catch((e: E2) => Stream.fromEffect( Effect.sync(() => { onError?.(e) - return }), - ) + ), ), Stream.runDrain, - Effect.forever, - Effect.forkDetach, ) - runtime.runCallback(subscription, { - onExit: (exit) => { - if (Exit.isSuccess(exit)) { - setFiberRef(exit.value as Fiber.Fiber) - } - }, - }) - - onCleanup(() => { - if (fiberRef() !== null) { - runtime.runCallback(Fiber.interrupt(fiberRef()!)) - } - }) + const cancel = runtime.runCallback(subscription) + onCleanup(cancel) return value } diff --git a/packages/solid/query/src/lib/types.ts b/packages/solid/query/src/lib/types.ts index ef128989..c678480e 100644 --- a/packages/solid/query/src/lib/types.ts +++ b/packages/solid/query/src/lib/types.ts @@ -6,29 +6,14 @@ import type { Accessor } from "solid-js" // Beta release trigger - v4 compatibility export type QueryKey = readonly [string, Record?] export type EffectfulError = { _tag: string } -export type Runner = () => Accessor< - (span: string) => (effect: Effect.Effect) => Promise -> -export type EffectfulMutationOptions< - TData, - TError extends EffectfulError, - TVariables, - R, -> = - & Omit< - UseMutationOptions, - | "mutationFn" - | "onSuccess" - | "onError" - | "onSettled" - | "onMutate" - | "retry" - | "retryDelay" - > - & { - mutationKey: QueryKey - mutationFn: (variables: TVariables) => Effect.Effect - } +export type Runner = () => Accessor<(span: string) => (effect: Effect.Effect) => Promise> +export type EffectfulMutationOptions = Omit< + UseMutationOptions, + "mutationFn" | "onSuccess" | "onError" | "onSettled" | "onMutate" | "retry" | "retryDelay" +> & { + mutationKey: QueryKey + mutationFn: (variables: TVariables) => Effect.Effect +} export type EffectfulQueryFunction< TData, @@ -36,33 +21,16 @@ export type EffectfulQueryFunction< TQueryKey extends QueryKey = QueryKey, R = never, TPageParam = never, -> = ( - context: QueryFunctionContext, -) => Effect.Effect - -export type EffectfulQueryOptions< - TData, - TError, - R, - TQueryKey extends QueryKey = QueryKey, - TPageParam = never, -> = - & Omit< - UseQueryOptions, - "queryKey" | "queryFn" | "retry" | "retryDelay" | "staleTime" | "gcTime" - > - & { - queryKey: TQueryKey - queryFn: - | EffectfulQueryFunction - | typeof skipToken - staleTime?: number - gcTime?: number - } +> = (context: QueryFunctionContext) => Effect.Effect -export interface Subscribable { - readonly changes: unknown - readonly get: () => A +export type EffectfulQueryOptions = Omit< + UseQueryOptions, + "queryKey" | "queryFn" | "retry" | "retryDelay" | "staleTime" | "gcTime" +> & { + queryKey: TQueryKey + queryFn: EffectfulQueryFunction | typeof skipToken + staleTime?: number + gcTime?: number } export interface SubscriptionOptions { diff --git a/packages/solid/query/tests/subscription-ref-contract.test.ts b/packages/solid/query/tests/subscription-ref-contract.test.ts new file mode 100644 index 00000000..7e3cecf6 --- /dev/null +++ b/packages/solid/query/tests/subscription-ref-contract.test.ts @@ -0,0 +1,68 @@ +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as ManagedRuntime from "effect/ManagedRuntime" +import * as SubscriptionRef from "effect/SubscriptionRef" +import { type Accessor, createContext, createRoot } from "solid-js" +import { describe, expect, expectTypeOf, it, vi } from "vitest" +import { makeUseRxSubscriptionRef } from "../src/lib/internal/make-use-rx-subsciption-ref.js" + +const settle = () => new Promise((resolve) => setTimeout(resolve, 10)) + +describe("Solid SubscriptionRef hook contract", () => { + it("returns an accessor that observes SubscriptionRef updates", async () => { + const subscriptionRef = Effect.runSync(SubscriptionRef.make(0)) + const runtime = ManagedRuntime.make(Layer.empty) + const RuntimeContext = createContext | null>(runtime) + const useRxSubscriptionRef = makeUseRxSubscriptionRef(RuntimeContext) + const onNext = vi.fn() + let dispose: (() => void) | undefined + + const value = createRoot((rootDispose) => { + dispose = rootDispose + return useRxSubscriptionRef(subscriptionRef, onNext, { skipInitial: false }) + }) + + try { + expectTypeOf(value).toEqualTypeOf>() + expect(value()).toBe(0) + await vi.waitFor(() => expect(onNext).toHaveBeenCalledWith(0)) + + await runtime.runPromise(SubscriptionRef.set(subscriptionRef, 1)) + + await vi.waitFor(() => expect(value()).toBe(1)) + expect(onNext).toHaveBeenLastCalledWith(1) + } finally { + dispose?.() + await runtime.dispose() + } + }) + + it("cancels the subscription when its owner is disposed", async () => { + const subscriptionRef = Effect.runSync(SubscriptionRef.make(0)) + const runtime = ManagedRuntime.make(Layer.empty) + const RuntimeContext = createContext | null>(runtime) + const useRxSubscriptionRef = makeUseRxSubscriptionRef(RuntimeContext) + const onNext = vi.fn() + let dispose: (() => void) | undefined + + createRoot((rootDispose) => { + dispose = rootDispose + useRxSubscriptionRef(subscriptionRef, onNext, { skipInitial: false }) + }) + + try { + await vi.waitFor(() => expect(onNext).toHaveBeenCalledWith(0)) + onNext.mockClear() + dispose?.() + dispose = undefined + + await runtime.runPromise(SubscriptionRef.set(subscriptionRef, 1)) + await settle() + + expect(onNext).not.toHaveBeenCalled() + } finally { + dispose?.() + await runtime.dispose() + } + }) +}) diff --git a/packages/solid/query/tests/types/subscription-ref-contract.ts b/packages/solid/query/tests/types/subscription-ref-contract.ts new file mode 100644 index 00000000..15e95036 --- /dev/null +++ b/packages/solid/query/tests/types/subscription-ref-contract.ts @@ -0,0 +1,25 @@ +import { QueryClient } from "@tanstack/solid-query" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as SubscriptionRef from "effect/SubscriptionRef" +import type { Accessor } from "solid-js" +import { expectTypeOf } from "vitest" +import { tanstackQueryEffect } from "../../src/index.js" + +const subscriptionRef = Effect.runSync(SubscriptionRef.make(0)) +const queryEffect = tanstackQueryEffect({ + layer: Layer.empty, + queryClient: new QueryClient(), +}) + +const usePublicContract = () => { + const value = queryEffect.useRxSubscriptionRef(subscriptionRef, () => {}) + expectTypeOf(value).toEqualTypeOf>() + + // @ts-expect-error SubscriptionRef acquisition must happen before setup. + queryEffect.useRxSubscriptionRef(SubscriptionRef.make(0), () => {}) + + // @ts-expect-error Arbitrary Effects are not subscribable refs. + queryEffect.useRxSubscriptionRef(Effect.succeed(subscriptionRef), () => {}) +} +void usePublicContract diff --git a/packages/solid/query/tsconfig.spec.json b/packages/solid/query/tsconfig.spec.json new file mode 100644 index 00000000..238ec1b9 --- /dev/null +++ b/packages/solid/query/tsconfig.spec.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["vitest/globals", "node"] + }, + "include": ["tests/**/*.ts", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/solid/query/vitest.config.ts b/packages/solid/query/vitest.config.ts new file mode 100644 index 00000000..b44eeb43 --- /dev/null +++ b/packages/solid/query/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + root: __dirname, + test: { + environment: "node", + allowOnly: false, + include: ["tests/**/*.test.ts"], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e6ddbac..5ee40846 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,9 +33,6 @@ catalogs: '@hatchet-dev/typescript-sdk': specifier: 1.28.2 version: 1.28.2 - '@kobalte/core': - specifier: 0.13.12 - version: 0.13.12 '@nx/cypress': specifier: 23.1.1 version: 23.1.1 @@ -108,9 +105,6 @@ catalogs: '@tanstack/router-plugin': specifier: ^1.168.19 version: 1.168.19 - '@tanstack/solid-form': - specifier: 1.33.0 - version: 1.33.0 '@tanstack/solid-query': specifier: 5.101.2 version: 5.101.2 @@ -711,33 +705,6 @@ importers: specifier: 'catalog:' version: 8.1.3(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.0)(yaml@2.9.0) - packages/chat/solid: - dependencies: - '@effectify/chat-domain': - specifier: workspace:* - version: link:../domain - '@effectify/solid-query': - specifier: workspace:* - version: link:../../solid/query - '@kobalte/core': - specifier: 'catalog:' - version: 0.13.12(solid-js@1.9.14) - '@tanstack/solid-form': - specifier: 'catalog:' - version: 1.33.0(solid-js@1.9.14) - '@tanstack/solid-query': - specifier: 'catalog:' - version: 5.101.2(solid-js@1.9.14) - effect: - specifier: 'catalog:' - version: 4.0.0-rc.111 - lucide-solid: - specifier: 'catalog:' - version: 0.554.0(solid-js@1.9.14) - solid-js: - specifier: 'catalog:' - version: 1.9.14 - packages/hatchet: dependencies: '@hatchet-dev/typescript-sdk': @@ -896,9 +863,15 @@ importers: '@types/react': specifier: 19.2.17 version: 19.2.17 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.17) effect: specifier: 'catalog:' version: 4.0.0-rc.111 + react-dom: + specifier: 'catalog:' + version: 19.2.7(react@19.2.7) typescript: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' @@ -2015,11 +1988,6 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} - '@corvu/utils@0.4.2': - resolution: {integrity: sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA==} - peerDependencies: - solid-js: ^1.8 - '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -2377,21 +2345,12 @@ packages: '@expressive-code/plugin-text-markers@0.44.1': resolution: {integrity: sha512-B3BsJoJ8CFMlcIX9f+X9tcI3C4zPDO601+YuLi9GheSTNro7ZfqSjLptMQKBHOWZvxnAtY5zvIX7iO/qtBhNBg==} - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - '@floating-ui/dom@1.8.0': resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} @@ -2599,9 +2558,6 @@ packages: cpu: [x64] os: [win32] - '@internationalized/number@3.6.6': - resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==} - '@ioredis/commands@1.5.1': resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} @@ -2720,16 +2676,6 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} - '@kobalte/core@0.13.12': - resolution: {integrity: sha512-aumsbsPe99/z2igcX2+p+Mdhzw89uuZx8ZdgfEqSlVuuWeTvkoBtCId5sl/Y7sWvllIOFwPoMzE3u0rAzDNkvg==} - peerDependencies: - solid-js: ^1.8.15 - - '@kobalte/utils@0.9.2': - resolution: {integrity: sha512-jRVXr+zsVHxzDXRoh+CDeXzvCsFJ6uiHhqqNQ26Cw9ZsZ3D6nqPUBt1gGVtj2ZPmRL3a9Uk1v8D1aJ8/I12Dow==} - peerDependencies: - solid-js: ^1.8.8 - '@lit-labs/ssr-dom-shim@1.5.1': resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} @@ -4505,56 +4451,11 @@ packages: '@sinonjs/fake-timers@15.3.2': resolution: {integrity: sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==} - '@solid-primitives/event-listener@2.4.5': - resolution: {integrity: sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA==} - peerDependencies: - solid-js: ^1.6.12 - - '@solid-primitives/keyed@1.5.3': - resolution: {integrity: sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA==} - peerDependencies: - solid-js: ^1.6.12 - - '@solid-primitives/map@0.4.13': - resolution: {integrity: sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew==} - peerDependencies: - solid-js: ^1.6.12 - - '@solid-primitives/media@2.3.5': - resolution: {integrity: sha512-LX9fB5WDaK87FMDtUB1qokBOfT2et9Uobv/zZaKLH9caFSz4+P70MBKEIBHcZQy+9MV5M2XvGYLTbLskjkzMjA==} - peerDependencies: - solid-js: ^1.6.12 - - '@solid-primitives/props@3.2.3': - resolution: {integrity: sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA==} - peerDependencies: - solid-js: ^1.6.12 - '@solid-primitives/refs@1.1.3': resolution: {integrity: sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA==} peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/resize-observer@2.1.5': - resolution: {integrity: sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw==} - peerDependencies: - solid-js: ^1.6.12 - - '@solid-primitives/rootless@1.5.3': - resolution: {integrity: sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA==} - peerDependencies: - solid-js: ^1.6.12 - - '@solid-primitives/static-store@0.1.3': - resolution: {integrity: sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ==} - peerDependencies: - solid-js: ^1.6.12 - - '@solid-primitives/trigger@1.2.3': - resolution: {integrity: sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng==} - peerDependencies: - solid-js: ^1.6.12 - '@solid-primitives/utils@6.4.0': resolution: {integrity: sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A==} peerDependencies: @@ -4962,11 +4863,6 @@ packages: resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} engines: {node: '>=20.19'} - '@tanstack/solid-form@1.33.0': - resolution: {integrity: sha512-+KXp+T/fD+nO5JarDj1W5GctEm4aFuz13DkHVJrdIHGo0evMXlFNWJT57n+TOyvoLoKehknKjM1o41rrKClSTQ==} - peerDependencies: - solid-js: '>=1.9.9' - '@tanstack/solid-query@5.101.2': resolution: {integrity: sha512-IbCeM23gEbU0lW4wWFQhS4ELjgdmVXjhyCUh8pFemdMIuA5UswlkqEMryEE+lCMtxLS7m0oN1yWMocKi7vwXdw==} peerDependencies: @@ -5023,11 +4919,6 @@ packages: vite: optional: true - '@tanstack/solid-store@0.11.0': - resolution: {integrity: sha512-2isL0ZnnyI1iN0V+QPrxE3OcPndohBgVlBcHZYoAOIAiU1WoWjVy0q5gb0suPu1Id0h5cKC23JnwzQTxWDZD0w==} - peerDependencies: - solid-js: ^1.6.0 - '@tanstack/start-client-core@1.170.13': resolution: {integrity: sha512-o37M3msIK5ec87kPrIYJWXb1XPnjIe5/jrkGLXiXpFuVL99z7mhoBCzftKtVPtzqI8EElnRE/VGFYT9BHNnWcw==} engines: {node: '>=22.12.0'} @@ -9796,16 +9687,6 @@ packages: solid-js@1.9.14: resolution: {integrity: sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ==} - solid-presence@0.1.8: - resolution: {integrity: sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA==} - peerDependencies: - solid-js: ^1.8 - - solid-prevent-scroll@0.1.10: - resolution: {integrity: sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw==} - peerDependencies: - solid-js: ^1.8 - solid-refresh@0.6.3: resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} peerDependencies: @@ -12121,11 +12002,6 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@corvu/utils@0.4.2(solid-js@1.9.14)': - dependencies: - '@floating-ui/dom': 1.7.6 - solid-js: 1.9.14 - '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -12446,26 +12322,15 @@ snapshots: dependencies: '@expressive-code/core': 0.44.1 - '@floating-ui/core@1.7.5': - dependencies: - '@floating-ui/utils': 0.2.11 - '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 - '@floating-ui/dom@1.7.6': - dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 - '@floating-ui/dom@1.8.0': dependencies: '@floating-ui/core': 1.8.0 '@floating-ui/utils': 0.2.12 - '@floating-ui/utils@0.2.11': {} - '@floating-ui/utils@0.2.12': {} '@grpc/grpc-js@1.14.4': @@ -12623,10 +12488,6 @@ snapshots: '@img/sharp-win32-x64@0.35.3': optional: true - '@internationalized/number@3.6.6': - dependencies: - '@swc/helpers': 0.5.23 - '@ioredis/commands@1.5.1': optional: true @@ -12821,28 +12682,6 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} - '@kobalte/core@0.13.12(solid-js@1.9.14)': - dependencies: - '@floating-ui/dom': 1.7.6 - '@internationalized/number': 3.6.6 - '@kobalte/utils': 0.9.2(solid-js@1.9.14) - '@solid-primitives/props': 3.2.3(solid-js@1.9.14) - '@solid-primitives/resize-observer': 2.1.5(solid-js@1.9.14) - solid-js: 1.9.14 - solid-presence: 0.1.8(solid-js@1.9.14) - solid-prevent-scroll: 0.1.10(solid-js@1.9.14) - - '@kobalte/utils@0.9.2(solid-js@1.9.14)': - dependencies: - '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.14) - '@solid-primitives/keyed': 1.5.3(solid-js@1.9.14) - '@solid-primitives/map': 0.4.13(solid-js@1.9.14) - '@solid-primitives/media': 2.3.5(solid-js@1.9.14) - '@solid-primitives/props': 3.2.3(solid-js@1.9.14) - '@solid-primitives/refs': 1.1.3(solid-js@1.9.14) - '@solid-primitives/utils': 6.4.0(solid-js@1.9.14) - solid-js: 1.9.14 - '@lit-labs/ssr-dom-shim@1.5.1': {} '@mdx-js/mdx@3.1.1': @@ -14868,61 +14707,11 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@solid-primitives/event-listener@2.4.5(solid-js@1.9.14)': - dependencies: - '@solid-primitives/utils': 6.4.0(solid-js@1.9.14) - solid-js: 1.9.14 - - '@solid-primitives/keyed@1.5.3(solid-js@1.9.14)': - dependencies: - solid-js: 1.9.14 - - '@solid-primitives/map@0.4.13(solid-js@1.9.14)': - dependencies: - '@solid-primitives/trigger': 1.2.3(solid-js@1.9.14) - solid-js: 1.9.14 - - '@solid-primitives/media@2.3.5(solid-js@1.9.14)': - dependencies: - '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.14) - '@solid-primitives/rootless': 1.5.3(solid-js@1.9.14) - '@solid-primitives/static-store': 0.1.3(solid-js@1.9.14) - '@solid-primitives/utils': 6.4.0(solid-js@1.9.14) - solid-js: 1.9.14 - - '@solid-primitives/props@3.2.3(solid-js@1.9.14)': - dependencies: - '@solid-primitives/utils': 6.4.0(solid-js@1.9.14) - solid-js: 1.9.14 - '@solid-primitives/refs@1.1.3(solid-js@1.9.14)': dependencies: '@solid-primitives/utils': 6.4.0(solid-js@1.9.14) solid-js: 1.9.14 - '@solid-primitives/resize-observer@2.1.5(solid-js@1.9.14)': - dependencies: - '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.14) - '@solid-primitives/rootless': 1.5.3(solid-js@1.9.14) - '@solid-primitives/static-store': 0.1.3(solid-js@1.9.14) - '@solid-primitives/utils': 6.4.0(solid-js@1.9.14) - solid-js: 1.9.14 - - '@solid-primitives/rootless@1.5.3(solid-js@1.9.14)': - dependencies: - '@solid-primitives/utils': 6.4.0(solid-js@1.9.14) - solid-js: 1.9.14 - - '@solid-primitives/static-store@0.1.3(solid-js@1.9.14)': - dependencies: - '@solid-primitives/utils': 6.4.0(solid-js@1.9.14) - solid-js: 1.9.14 - - '@solid-primitives/trigger@1.2.3(solid-js@1.9.14)': - dependencies: - '@solid-primitives/utils': 6.4.0(solid-js@1.9.14) - solid-js: 1.9.14 - '@solid-primitives/utils@6.4.0(solid-js@1.9.14)': dependencies: solid-js: 1.9.14 @@ -15385,12 +15174,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/solid-form@1.33.0(solid-js@1.9.14)': - dependencies: - '@tanstack/form-core': 1.33.0 - '@tanstack/solid-store': 0.11.0(solid-js@1.9.14) - solid-js: 1.9.14 - '@tanstack/solid-query@5.101.2(solid-js@1.9.14)': dependencies: '@tanstack/query-core': 5.101.2 @@ -15500,11 +15283,6 @@ snapshots: - webpack optional: true - '@tanstack/solid-store@0.11.0(solid-js@1.9.14)': - dependencies: - '@tanstack/store': 0.11.0 - solid-js: 1.9.14 - '@tanstack/start-client-core@1.170.13': dependencies: '@tanstack/router-core': 1.171.14 @@ -21926,16 +21704,6 @@ snapshots: seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) - solid-presence@0.1.8(solid-js@1.9.14): - dependencies: - '@corvu/utils': 0.4.2(solid-js@1.9.14) - solid-js: 1.9.14 - - solid-prevent-scroll@0.1.10(solid-js@1.9.14): - dependencies: - '@corvu/utils': 0.4.2(solid-js@1.9.14) - solid-js: 1.9.14 - solid-refresh@0.6.3(solid-js@1.9.14): dependencies: '@babel/generator': 7.29.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b9e1bb76..c742ec7d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,7 +17,6 @@ catalog: "@effect/tsgo": 0.36.5 "@effect/vitest": 4.0.0-rc.111 "@hatchet-dev/typescript-sdk": 1.28.2 - "@kobalte/core": 0.13.12 "@nx/js": 23.1.1 "@nx/node": 23.1.1 "@nx/react": 23.1.1 @@ -53,7 +52,6 @@ catalog: "@tanstack/react-router": 1.139.3 "@tanstack/react-router-devtools": 1.139.3 "@tanstack/router-plugin": ^1.168.19 - "@tanstack/solid-form": 1.33.0 "@tanstack/solid-query": 5.101.2 "@tanstack/solid-query-devtools": 5.101.2 "@tanstack/solid-router": ^1.170.17 diff --git a/scripts/release-finalize-stable.mjs b/scripts/release-finalize-stable.mjs index 4ec735f5..0639117d 100644 --- a/scripts/release-finalize-stable.mjs +++ b/scripts/release-finalize-stable.mjs @@ -1,21 +1,16 @@ #!/usr/bin/env node -import { readFile } from "node:fs/promises" import { spawn } from "node:child_process" +import { realpathSync } from "node:fs" +import { readFile } from "node:fs/promises" +import { fileURLToPath, pathToFileURL } from "node:url" +import { isDeepStrictEqual } from "node:util" -const records = [ - ["@effectify/hatchet", "packages/hatchet/package.json", "0.1.0"], - ["@effectify/node-better-auth", "packages/node/better-auth/package.json", "0.5.12"], - ["@effectify/prisma", "packages/prisma/package.json", "1.1.13"], - ["@effectify/react-query", "packages/react/query/package.json", "1.0.0"], - ["@effectify/react-router", "packages/react/router/package.json", "0.6.0"], - ["@effectify/react-router-better-auth", "packages/react/router-better-auth/package.json", "0.5.12"], - ["@effectify/solid-query", "packages/solid/query/package.json", "0.5.13"], -] const expectedSha = process.env.EXPECTED_SHA ?? "" const artifactSha = process.env.ARTIFACT_SHA || expectedSha +const requestedProjectsText = process.env.PROJECTS ?? "" const historicalReplay = artifactSha !== expectedSha const maxReads = 6 -const delayMs = Number(process.env.NPM_READ_DELAY_MS ?? (Number(process.env.NPM_READ_DELAY ?? 10) * 1000)) +const delayMs = Number(process.env.NPM_READ_DELAY_MS ?? Number(process.env.NPM_READ_DELAY ?? 10) * 1000) const commandTimeoutMs = Number(process.env.FINALIZE_COMMAND_TIMEOUT_MS ?? 60_000) const httpTimeoutMs = Number(process.env.FINALIZE_HTTP_TIMEOUT_MS ?? 30_000) const outputLimit = Number(process.env.FINALIZE_OUTPUT_LIMIT ?? 1024 * 1024) @@ -23,20 +18,37 @@ const cliArguments = process.argv.slice(2) const preflight = cliArguments.includes("--preflight") const jsonOutput = cliArguments.includes("--json") -function fail(message) { throw new Error(message) } -function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } -function run(file, args, { ok = [0] } = {}) { +function fail(message) { + throw new Error(message) +} +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} +function run(file, args, { ok = [0], env } = {}) { return new Promise((resolve, reject) => { - const child = spawn(file, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] }) - let stdout = Buffer.alloc(0), stderr = Buffer.alloc(0), excessive = false + const child = spawn(file, args, { shell: false, stdio: ["ignore", "pipe", "pipe"], env }) + let stdout = Buffer.alloc(0), + stderr = Buffer.alloc(0), + excessive = false const append = (current, chunk) => { - if (current.length + chunk.length > outputLimit) { excessive = true; child.kill("SIGKILL"); return current } + if (current.length + chunk.length > outputLimit) { + excessive = true + child.kill("SIGKILL") + return current + } return Buffer.concat([current, chunk]) } - child.stdout.on("data", (x) => { stdout = append(stdout, x) }) - child.stderr.on("data", (x) => { stderr = append(stderr, x) }) + child.stdout.on("data", (chunk) => { + stdout = append(stdout, chunk) + }) + child.stderr.on("data", (chunk) => { + stderr = append(stderr, chunk) + }) const timer = setTimeout(() => child.kill("SIGKILL"), commandTimeoutMs) - child.on("error", (error) => { clearTimeout(timer); reject(new Error(`${file} execution failed: ${error.message}`)) }) + child.on("error", (error) => { + clearTimeout(timer) + reject(new Error(`${file} execution failed: ${error.message}`)) + }) child.on("close", (code, signal) => { clearTimeout(timer) const result = { code, signal, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") } @@ -47,52 +59,300 @@ function run(file, args, { ok = [0] } = {}) { }) }) } -function parseJson(text, label) { try { return JSON.parse(text) } catch { fail(`${label} returned malformed JSON`) } } -async function manifest(name, path, version) { - let value - try { value = parseJson(await readFile(path, "utf8"), `manifest ${name}`) } catch (error) { fail(`merged manifest execution or parse failed for ${name}: ${error.message}`) } - const valid = value && typeof value === "object" && !Array.isArray(value) && typeof value.name === "string" && typeof value.version === "string" - if (!valid || value.name !== name || value.version !== version) fail(`merged manifest identity mismatch for ${name}: actual=${JSON.stringify({ name: valid ? value.name : null, version: valid ? value.version : null })} expected=${JSON.stringify({ name, version })}`) +function parseJson(text, label) { + try { + return JSON.parse(text) + } catch { + fail(`${label} returned malformed JSON`) + } +} +function object(value) { + return value && typeof value === "object" && !Array.isArray(value) +} +function safeRoot(value) { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= 512 && + !value.startsWith("/") && + !value.includes("\\") && + !value.includes("\u0000") && + !value.includes("//") && + value.split("/").every((part) => part && part !== "." && part !== "..") + ) +} +function safeName(value) { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= 214 && + !/[\s,]/.test(value) && + !value.includes("\u0000") + ) +} +function parseSemver(value) { + if (typeof value !== "string") return null + const match = value.match( + /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/, + ) + if (!match) return null + const prerelease = match[4]?.split(".") ?? [] + if ( + prerelease.some((identifier) => /^[0-9]+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0")) + ) + return null + return { major: BigInt(match[1]), minor: BigInt(match[2]), patch: BigInt(match[3]), prerelease } +} +function compareSemver(left, right) { + for (const part of ["major", "minor", "patch"]) { + if (left[part] < right[part]) return -1 + if (left[part] > right[part]) return 1 + } + if (left.prerelease.length === 0 || right.prerelease.length === 0) { + return left.prerelease.length === right.prerelease.length ? 0 : left.prerelease.length === 0 ? 1 : -1 + } + const length = Math.max(left.prerelease.length, right.prerelease.length) + for (let index = 0; index < length; index++) { + const leftIdentifier = left.prerelease[index], + rightIdentifier = right.prerelease[index] + if (leftIdentifier === undefined || rightIdentifier === undefined) return leftIdentifier === undefined ? -1 : 1 + if (leftIdentifier === rightIdentifier) continue + const leftNumeric = /^[0-9]+$/.test(leftIdentifier), + rightNumeric = /^[0-9]+$/.test(rightIdentifier) + if (leftNumeric && rightNumeric) return BigInt(leftIdentifier) < BigInt(rightIdentifier) ? -1 : 1 + if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1 + return leftIdentifier < rightIdentifier ? -1 : 1 + } + return 0 +} +function parseRequestedProjects() { + const raw = requestedProjectsText + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + if (raw.length === 0) fail("stable selection is empty") + const duplicates = raw.filter((value, index) => raw.indexOf(value) !== index) + if (duplicates.length > 0) fail(`duplicate requested project: ${duplicates[0]}`) + return raw.sort() +} +async function commitParents(revision, label) { + let result + try { + result = await run("git", ["rev-list", "--parents", "-n", "1", revision]) + } catch { + fail(`${label} commit shape is unreadable`) + } + const parts = result.stdout.trimEnd().split(" ") + if ( + parts.length < 2 || + parts.some((part) => !/^[0-9a-f]{40}$/.test(part)) || + parts[0] !== revision || + new Set(parts).size !== parts.length + ) { + fail(`${label} commit shape is invalid`) + } + return parts.slice(1) +} +async function verifyArtifactLineage() { + const parents = await commitParents(artifactSha, "reviewed artifact") + if (parents.length === 1) return + if (parents.length !== 2) fail("reviewed artifact must be a single-parent commit or exact two-parent merge") + + const [firstParent, generatedParent] = parents + const generatedParents = await commitParents(generatedParent, "reviewed merge second parent") + if (generatedParents.length !== 1 || generatedParents[0] !== firstParent) { + fail("reviewed merge second parent must be a single commit based directly on first parent") + } + + let trees + try { + trees = await run("git", ["rev-parse", `${generatedParent}^{tree}`, `${artifactSha}^{tree}`]) + } catch { + fail("reviewed merge trees are unreadable") + } + const treeIds = trees.stdout.trimEnd().split("\n") + if (treeIds.length !== 2 || treeIds.some((tree) => !/^[0-9a-f]{40}$/.test(tree)) || treeIds[0] !== treeIds[1]) { + fail("reviewed merge tree must exactly match its generated second parent") + } +} +async function verifyHistoricalAncestry() { + let result + try { + result = await run("git", ["merge-base", "--is-ancestor", artifactSha, expectedSha], { ok: [0, 1] }) + } catch { + fail("historical artifact ancestry is unreadable") + } + if (result.code !== 0) fail("historical artifact SHA must be an ancestor of expected SHA") +} +async function verifyArtifactChangelog() { + let result + try { + result = await run("git", ["cat-file", "-t", `${artifactSha}:CHANGELOG.md`]) + } catch { + fail("reviewed artifact requires root CHANGELOG.md to exist as a blob") + } + if (result.stdout !== "blob\n") fail("reviewed artifact requires root CHANGELOG.md to exist as a blob") +} +async function artifactJson(path, revision = artifactSha) { + let result + try { + result = await run("git", ["show", `${revision}:${path}`]) + } catch { + fail(`artifact repository read failed for ${revision}:${path}`) + } + return parseJson(result.stdout, `artifact ${revision}:${path}`) +} +async function deriveReviewedRecords(projects) { + const nx = await artifactJson("nx.json") + const roots = nx?.release?.projects + if (!Array.isArray(roots) || roots.length === 0 || roots.some((root) => !safeRoot(root))) { + fail("artifact nx.json release projects are invalid") + } + if (new Set(roots).size !== roots.length) fail("artifact nx.json release projects contain duplicates") + + const catalog = [] + const projectNames = new Set(), + packageNames = new Set(), + manifestPaths = new Set() + for (const root of roots) { + const projectJson = await artifactJson(`${root}/project.json`) + const manifestPath = `${root}/package.json` + const manifest = await artifactJson(manifestPath) + const project = projectJson?.name, + name = manifest?.name, + version = manifest?.version + if (!object(projectJson) || !safeName(project)) fail(`artifact project identity is invalid for ${root}`) + if (!object(manifest) || !safeName(name) || typeof version !== "string") { + fail(`artifact manifest identity is invalid for ${manifestPath}`) + } + if (project !== name) fail(`artifact project and manifest identity mismatch for ${manifestPath}`) + if (projectNames.has(project) || packageNames.has(name) || manifestPaths.has(manifestPath)) { + fail(`artifact release identity is duplicated for ${project}`) + } + projectNames.add(project) + packageNames.add(name) + manifestPaths.add(manifestPath) + catalog.push({ project, root, manifestPath, name, version, reviewedManifest: manifest }) + } + for (const project of projects) + if (!projectNames.has(project)) fail(`requested project is not in artifact release projects: ${project}`) + + const changedResult = await run("git", ["diff", "--name-only", "--no-renames", `${artifactSha}^1`, artifactSha]) + const changedPaths = changedResult.stdout.split("\n").filter(Boolean) + if (new Set(changedPaths).size !== changedPaths.length) fail("reviewed diff contains duplicate paths") + if (!changedPaths.includes("CHANGELOG.md")) fail("reviewed diff requires root CHANGELOG.md") + + const byManifest = new Map(catalog.map((item) => [item.manifestPath, item])) + const records = [] + for (const changedPath of changedPaths) { + if (changedPath === "CHANGELOG.md") continue + const item = byManifest.get(changedPath) + if (!item) fail(`unexpected reviewed path: ${changedPath}`) + const previous = await artifactJson(item.manifestPath, `${artifactSha}^1`) + if ( + !object(previous) || + !safeName(previous.name) || + typeof previous.version !== "string" || + previous.name !== item.name + ) { + fail(`reviewed manifest identity mismatch for ${item.manifestPath}`) + } + const match = previous.version.match(/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.(0|[1-9][0-9]*)$/) + const stableVersion = match ? `${match[1]}.${match[2]}.${match[3]}` : "" + if (!match || item.version !== stableVersion) { + fail(`reviewed manifest is not a strict beta-to-stable transition for ${item.project}`) + } + records.push({ ...item, version: stableVersion, betaVersion: previous.version }) + } + if (records.length === 0) fail("reviewed diff contains no release manifest transition") + records.sort((left, right) => left.project.localeCompare(right.project)) + const reviewedProjects = records.map((item) => item.project) + if ( + projects.length !== reviewedProjects.length || + projects.some((project, index) => project !== reviewedProjects[index]) + ) { + fail("requested projects do not exactly match reviewed manifest changes") + } + return records } -async function npmState(name, version) { +async function npmState(name, version, betaVersion) { try { const versionsDoc = (await run("npm", ["view", name, "versions", "--json"])).stdout - const latestDoc = (await run("npm", ["view", name, "dist-tags.latest", "--json"])).stdout - const versions = parseJson(versionsDoc, `${name} versions`), latest = parseJson(latestDoc, `${name} latest`) - if (!((typeof versions === "string") || (Array.isArray(versions) && versions.every((x) => typeof x === "string"))) || typeof latest !== "string") return { kind: "unknown" } - const present = Array.isArray(versions) ? versions.includes(version) : versions === version - return !present ? { kind: "absent" } : latest === version ? { kind: "exact" } : { kind: "divergent" } - } catch { return { kind: "unknown" } } -} -async function npmBounded(name, version, { acceptAbsent = false } = {}) { + const tagsDoc = (await run("npm", ["view", name, "dist-tags", "--json"])).stdout + const versionsValue = parseJson(versionsDoc, `${name} versions`), + tags = parseJson(tagsDoc, `${name} dist-tags`) + const versions = typeof versionsValue === "string" ? [versionsValue] : versionsValue + if (!Array.isArray(versions) || versions.some((value) => typeof value !== "string") || !object(tags)) + return { kind: "unknown" } + if (new Set(versions).size !== versions.length || versions.some((value) => !parseSemver(value))) + return { kind: "unknown" } + const target = parseSemver(version), + beta = parseSemver(betaVersion) + if (!target || !beta) return { kind: "unknown" } + const versionSet = new Set(versions), + targetPresent = versionSet.has(version) + const hasLatest = Object.hasOwn(tags, "latest") + let latest + if (hasLatest) { + if (typeof tags.latest !== "string" || !(latest = parseSemver(tags.latest))) return { kind: "unknown" } + if (!versionSet.has(tags.latest)) return { kind: "divergent" } + } + if (targetPresent) return hasLatest && tags.latest === version ? { kind: "exact" } : { kind: "divergent" } + if (tags.beta !== betaVersion || !versionSet.has(betaVersion)) return { kind: "divergent" } + if (hasLatest && compareSemver(latest, target) >= 0) return { kind: "divergent" } + return { kind: "absent" } + } catch { + return { kind: "unknown" } + } +} +async function npmBounded(name, version, betaVersion, { acceptAbsent = false } = {}) { let state for (let attempt = 1; attempt <= maxReads; attempt++) { - state = await npmState(name, version) + state = await npmState(name, version, betaVersion) if (state.kind === "exact" || (acceptAbsent && state.kind === "absent")) return state if (attempt < maxReads) await sleep(delayMs) } if (state.kind === "absent") fail(`npm version remained absent after ${maxReads} attempts for ${name}`) - fail(state.kind === "divergent" ? `permanent latest divergence for ${name}` : `npm state unreadable after ${maxReads} attempts for ${name}`) + fail( + state.kind === "divergent" + ? `permanent npm state divergence for ${name}` + : `npm state unreadable after ${maxReads} attempts for ${name}`, + ) } function parseTag(text, tag) { - const direct = [], peeled = [], directRef = `refs/tags/${tag}`, peeledRef = `${directRef}^{}` + const direct = [], + peeled = [], + directRef = `refs/tags/${tag}`, + peeledRef = `${directRef}^{}` if (text === "") return { kind: "absent" } for (const line of text.split("\n")) { if (!line) continue const match = line.match(/^([0-9a-f]{40})\t([^\s]+)$/) if (!match) return { kind: "unknown" } - if (match[2] === directRef) direct.push(match[1]); else if (match[2] === peeledRef) peeled.push(match[1]); else return { kind: "unknown" } + if (match[2] === directRef) direct.push(match[1]) + else if (match[2] === peeledRef) peeled.push(match[1]) + else return { kind: "unknown" } } - return direct.length === 1 && peeled.length === 1 && peeled[0] === artifactSha ? { kind: "exact" } : { kind: "divergent" } + return direct.length === 1 && peeled.length === 1 && peeled[0] === artifactSha + ? { kind: "exact" } + : { kind: "divergent" } } async function tagState(tag) { let result - try { result = await run("git", ["ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`]) } catch { return { kind: "unknown" } } + try { + result = await run("git", ["ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`]) + } catch { + return { kind: "unknown" } + } return parseTag(result.stdout, tag) } async function localTagState(tag) { let result - try { result = await run("git", ["for-each-ref", "--format=%(objecttype)%09%(*objectname)", `refs/tags/${tag}`]) } catch { return { kind: "unknown" } } + try { + result = await run("git", ["for-each-ref", "--format=%(objecttype)%09%(*objectname)", `refs/tags/${tag}`]) + } catch { + return { kind: "unknown" } + } if (result.stdout === "") return { kind: "absent" } const lines = result.stdout.trimEnd().split("\n") if (lines.length !== 1) return { kind: "divergent" } @@ -104,77 +364,185 @@ function repository() { fail("GITHUB_REPOSITORY is required") } async function github(method, path, body) { - const controller = new AbortController(), timer = setTimeout(() => controller.abort(), httpTimeoutMs) + const controller = new AbortController(), + timer = setTimeout(() => controller.abort(), httpTimeoutMs) try { const options = { - method, signal: controller.signal, - headers: { accept: "application/vnd.github+json", authorization: `Bearer ${process.env.GITHUB_TOKEN ?? ""}`, "content-type": "application/json", "user-agent": "effectify-release-finalizer", "x-github-api-version": "2022-11-28" }, + method, + signal: controller.signal, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${process.env.GITHUB_TOKEN ?? ""}`, + "content-type": "application/json", + "user-agent": "effectify-release-finalizer", + "x-github-api-version": "2022-11-28", + }, } if (body !== undefined) options.body = JSON.stringify(body) - const response = await fetch(`${process.env.GITHUB_API_URL ?? "https://api.github.com"}/repos/${repository()}${path}`, options) + const response = await fetch( + `${process.env.GITHUB_API_URL ?? "https://api.github.com"}/repos/${repository()}${path}`, + options, + ) const text = await response.text() return { status: response.status, text } - } catch (error) { fail(`GitHub transport failure: ${error.message}`) } finally { clearTimeout(timer) } + } catch (error) { + fail(`GitHub transport failure: ${error.message}`) + } finally { + clearTimeout(timer) + } } async function releaseState(tag) { const result = await github("GET", `/releases/tags/${encodeURIComponent(tag)}`) if (result.status === 404) return { kind: "absent" } if (result.status !== 200) return { kind: "unknown", status: result.status } const value = parseJson(result.text, `GitHub Release ${tag}`) - return value && typeof value === "object" && !Array.isArray(value) && value.tag_name === tag && value.draft === false && value.prerelease === false ? { kind: "exact" } : { kind: "divergent" } + return object(value) && value.tag_name === tag && value.draft === false && value.prerelease === false + ? { kind: "exact" } + : { kind: "divergent" } +} +async function verifyPublicationSource(records) { + const status = await run("git", ["status", "--porcelain=v1", "--untracked-files=all"]) + if (status.stdout !== "") fail("stable publication requires a clean index and worktree") + for (const record of records) { + let text + try { + text = await readFile(record.manifestPath, "utf8") + } catch { + fail(`on-disk manifest is unreadable for ${record.manifestPath}`) + } + const manifest = parseJson(text, `on-disk manifest ${record.manifestPath}`) + if (!object(manifest) || !isDeepStrictEqual(manifest, record.reviewedManifest)) { + fail(`on-disk manifest does not exactly match reviewed artifact for ${record.manifestPath}`) + } + } } -async function inspect() { +async function inspect(records) { await run("git", ["fetch", "origin", "master:refs/remotes/origin/master", "--no-tags"]) - const head = (await run("git", ["rev-parse", "HEAD"])).stdout.trim(), origin = (await run("git", ["rev-parse", "origin/master"])).stdout.trim() + const head = (await run("git", ["rev-parse", "HEAD"])).stdout.trim() + const origin = (await run("git", ["rev-parse", "origin/master"])).stdout.trim() if (head !== expectedSha) fail("HEAD does not match expected SHA") if (origin !== expectedSha) fail("origin/master does not match expected SHA") const states = [] - for (const [name, path, version] of records) { - await manifest(name, path, version) - const npm = await npmBounded(name, version, { acceptAbsent: true }), tag = await tagState(`${name}@${version}`), release = await releaseState(`${name}@${version}`) - for (const [label, state] of [["tag", tag], ["GitHub Release", release]]) if (!['exact','absent'].includes(state.kind)) fail(`${label} state is ${state.kind} for ${name}@${version}${state.status ? ` (HTTP ${state.status})` : ""}`) - states.push({ name, version, npm: npm.kind, tag: tag.kind, release: release.kind }) + for (const record of records) { + const npm = await npmBounded(record.name, record.version, record.betaVersion, { acceptAbsent: true }) + const tag = await tagState(`${record.name}@${record.version}`) + const release = await releaseState(`${record.name}@${record.version}`) + for (const [label, state] of [ + ["tag", tag], + ["GitHub Release", release], + ]) { + if (!["exact", "absent"].includes(state.kind)) + fail( + `${label} state is ${state.kind} for ${record.name}@${record.version}${state.status ? ` (HTTP ${state.status})` : ""}`, + ) + } + const state = { ...record, npm: npm.kind, tag: tag.kind, release: release.kind } + delete state.reviewedManifest + states.push(state) } return states } async function main() { - if (cliArguments.some((x) => !["--preflight", "--json"].includes(x))) fail("unknown argument") + if (cliArguments.some((argument) => !["--preflight", "--json"].includes(argument))) fail("unknown argument") if (jsonOutput && !preflight) fail("--json requires --preflight") if (!/^[0-9a-f]{40}$/.test(expectedSha)) fail("FINALIZE requires full lowercase expected SHA") if (!/^[0-9a-f]{40}$/.test(artifactSha)) fail("FINALIZE requires full lowercase artifact SHA") - const states = await inspect() + const projects = parseRequestedProjects() + if (!preflight && process.env.GITHUB_ACTIONS !== "true") + fail("FINALIZE publication is allowed only in GitHub Actions") + if (historicalReplay) await verifyHistoricalAncestry() + await verifyArtifactLineage() + await verifyArtifactChangelog() + const records = await deriveReviewedRecords(projects) + const states = await inspect(records) if (historicalReplay) { const incomplete = states.find((item) => item.tag !== "exact" || item.release !== "exact" || item.npm !== "exact") - if (incomplete) fail(`historical replay requires exact existing tag, GitHub Release, and npm latest for ${incomplete.name}@${incomplete.version}`) + if (incomplete) + fail( + `historical replay requires exact existing tag, GitHub Release, and npm latest for ${incomplete.name}@${incomplete.version}`, + ) } - if (preflight) { process.stdout.write(`${JSON.stringify({ ok: true, expectedSha, artifactSha, states })}\n`); return } - const missingTags = states.filter((x) => x.tag === "absent") + if (preflight) process.stdout.write(`${JSON.stringify({ ok: true, expectedSha, artifactSha, projects, states })}\n`) + if (historicalReplay || preflight) return + + await verifyPublicationSource(records) + const missingTags = states.filter((item) => item.tag === "absent") const localTags = [] for (const item of missingTags) { - const tag = `${item.name}@${item.version}`, local = await localTagState(tag) - if (!['exact','absent'].includes(local.kind)) fail(`local tag state is ${local.kind} for ${tag}`) - localTags.push({ item, tag, local: local.kind }) + const tag = `${item.name}@${item.version}`, + local = await localTagState(tag) + if (!["exact", "absent"].includes(local.kind)) fail(`local tag state is ${local.kind} for ${tag}`) + localTags.push({ tag, local: local.kind }) } - if (localTags.some((x) => x.local === "absent")) { + if (localTags.some((item) => item.local === "absent")) { await run("git", ["config", "user.name", "github-actions[bot]"]) await run("git", ["config", "user.email", "github-actions[bot]@users.noreply.github.com"]) } - for (const { tag, local } of localTags) if (local === "absent") await run("git", ["tag", "-a", tag, artifactSha, "-m", tag]) - if (missingTags.length) { - const refs = missingTags.map((x) => `refs/tags/${x.name}@${x.version}:refs/tags/${x.name}@${x.version}`) - try { await run("git", ["push", "--atomic", "origin", ...refs]) } catch { /* response loss is reconciled below */ } + for (const { tag, local } of localTags) + if (local === "absent") await run("git", ["tag", "-a", tag, artifactSha, "-m", tag]) + if (missingTags.length > 0) { + const refs = missingTags.map( + (item) => `refs/tags/${item.name}@${item.version}:refs/tags/${item.name}@${item.version}`, + ) + try { + await run("git", ["push", "--atomic", "origin", ...refs]) + } catch { + /* response loss is reconciled below */ + } + } + for (const item of states) + if ((await tagState(`${item.name}@${item.version}`)).kind !== "exact") + fail(`remote tag postverification failed for ${item.name}@${item.version}`) + + for (const item of states.filter((state) => state.release === "absent")) { + let result + try { + result = await github("POST", "/releases", { + tag_name: `${item.name}@${item.version}`, + generate_release_notes: true, + draft: false, + prerelease: false, + }) + } catch { + /* response loss is reconciled below */ + } + if (result && ![201, 422].includes(result.status)) + fail(`GitHub Release creation failed for ${item.name}@${item.version} (HTTP ${result.status})`) + } + for (const item of states) + if ((await releaseState(`${item.name}@${item.version}`)).kind !== "exact") + fail(`GitHub Release postverification failed for ${item.name}@${item.version}`) + + const missing = [] + for (const item of states.filter((state) => state.npm === "absent")) { + const current = await npmBounded(item.name, item.version, item.betaVersion, { acceptAbsent: true }) + if (current.kind === "absent") missing.push(item.project) + } + if (missing.length > 0) { + await verifyPublicationSource(records) + await run("pnpm", ["nx", "release", "publish", `--projects=${missing.join(",")}`], { + env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: "true" }, + }) + } + for (const item of states) { + const state = await npmBounded(item.name, item.version, item.betaVersion) + if (state.kind !== "exact") fail(`npm did not converge for ${item.name}`) } - for (const item of states) if ((await tagState(`${item.name}@${item.version}`)).kind !== "exact") fail(`remote tag postverification failed for ${item.name}@${item.version}`) - for (const item of states.filter((x) => x.release === "absent")) { - const result = await github("POST", "/releases", { tag_name: `${item.name}@${item.version}`, generate_release_notes: true, draft: false, prerelease: false }) - if (![201, 422].includes(result.status)) fail(`GitHub Release creation failed for ${item.name}@${item.version} (HTTP ${result.status})`) +} +function isMainModule() { + const entry = process.argv[1] + if (!entry) return false + let resolvedEntry, resolvedModule + try { + resolvedEntry = realpathSync(entry) + resolvedModule = realpathSync(fileURLToPath(import.meta.url)) + } catch { + return false } - for (const item of states) if ((await releaseState(`${item.name}@${item.version}`)).kind !== "exact") fail(`GitHub Release postverification failed for ${item.name}@${item.version}`) - const missing = states.filter((x) => x.npm === "absent").map((x) => x.name) - if (missing.length) await run("pnpm", ["nx", "release", "publish", `--projects=${missing.join(",")}`]) - for (const item of states) { const state = await npmBounded(item.name, item.version); if (state.kind !== "exact") fail(`npm did not converge for ${item.name}`) } + return pathToFileURL(resolvedEntry).href === pathToFileURL(resolvedModule).href } -const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href -if (isMain) main().catch((error) => { process.stderr.write(`::error::${error.message}\n`); process.exitCode = 1 }) - -export { parseJson, parseTag } +if (isMainModule()) + main().catch((error) => { + process.stderr.write(`::error::${error.message}\n`) + process.exitCode = 1 + }) diff --git a/scripts/release-finalize-stable.test.mjs b/scripts/release-finalize-stable.test.mjs index c926a9ed..ffca2b84 100644 --- a/scripts/release-finalize-stable.test.mjs +++ b/scripts/release-finalize-stable.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { spawn } from "node:child_process" -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs" +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs" +import { rm } from "node:fs/promises" import { createServer } from "node:http" import { tmpdir } from "node:os" import { join } from "node:path" @@ -10,15 +11,22 @@ const script = new URL("release-finalize-stable.mjs", import.meta.url).pathname const stableWorkflow = readFileSync(new URL("../.github/workflows/release-stable.yml", import.meta.url), "utf8") const sha = "1234567890abcdef1234567890abcdef12345678" const historicalSha = "abcdef1234567890abcdef1234567890abcdef12" -const records = [ - ["@effectify/hatchet", "packages/hatchet/package.json", "0.1.0"], - ["@effectify/node-better-auth", "packages/node/better-auth/package.json", "0.5.12"], - ["@effectify/prisma", "packages/prisma/package.json", "1.1.13"], - ["@effectify/react-query", "packages/react/query/package.json", "1.0.0"], - ["@effectify/react-router", "packages/react/router/package.json", "0.6.0"], - ["@effectify/react-router-better-auth", "packages/react/router-better-auth/package.json", "0.5.12"], - ["@effectify/solid-query", "packages/solid/query/package.json", "0.5.13"], +const parentSha = "fedcba0987654321fedcba0987654321fedcba09" +const secondParentSha = "0123456789abcdef0123456789abcdef01234567" +const thirdParentSha = "89abcdef0123456789abcdef0123456789abcdef" +const treeSha = "9999999999999999999999999999999999999999" +const catalog = [ + ["@future/nebula", "packages/future/nebula", "4.7.0-beta.12", "4.7.0"], + ["@future/orbit", "packages/future/orbit", "8.0.1-beta.3", "8.0.1"], + ["@future/quasar", "packages/future/quasar", "12.3.5-beta.27", "12.3.5"], ] +const selected = [catalog[0], catalog[2]] +const records = selected.map(([project, root, beta, version]) => [project, `${root}/package.json`, version, beta]) +const selectedProjects = records + .map(([project]) => project) + .sort() + .join(",") + const fake = String.raw`#!/usr/bin/env node const fs=require('fs'),p=require('path'),cmd=p.basename(process.argv[1]),a=process.argv.slice(2),f=process.env.FAKE_STATE let s=JSON.parse(fs.readFileSync(f)), out=x=>process.stdout.write(String(x)), save=()=>fs.writeFileSync(f,JSON.stringify(s)) @@ -26,119 +34,1262 @@ s.log.push([cmd,...a]); function finish(code=0){save();process.exit(code)} if(cmd==='git'){ if(a[0]==='fetch'||a[0]==='config')finish() - if(a[0]==='rev-parse'){out((a[1]==='HEAD'?s.head:s.origin)+'\n');finish()} + if(a[0]==='cat-file'&&a[1]==='-t'){const v=s.gitFiles[a[2]];if(v===undefined)finish(128);out((s.gitTypes?.[a[2]]??'blob')+'\n');finish()} + if(a[0]==='merge-base'){finish(s.ancestorExit??(s.ancestor===false?1:0))} + if(a[0]==='rev-list'){out((s.commitLines?.[a.at(-1)]??s.commitLine??(s.artifactSha+' '+s.parentSha))+'\n');finish()} + if(a[0]==='rev-parse'){ + if(a[1].endsWith('^{tree}')){out((s.generatedTreeSha??s.treeSha)+'\n'+(s.artifactTreeSha??s.treeSha)+'\n');finish()} + out((a[1]==='HEAD'?s.head:s.origin)+'\n');finish() + } + if(a[0]==='status'){out(s.worktreeStatus??'');finish()} + if(a[0]==='show'){const value=s.gitFiles[a[1]];if(value===undefined)finish(128);out(typeof value==='string'?value:JSON.stringify(value));finish()} + if(a[0]==='diff'){out(s.changedPaths.join('\n')+(s.changedPaths.length?'\n':''));finish()} if(a[0]==='ls-remote'){ - const t=a[3].slice(10),v=s.tags[t]; if(v){if(v.raw)out(v.raw.replaceAll('$TAG',t));else{out((v.direct||'a'.repeat(40))+'\trefs/tags/'+t+'\n');if(v.peeled!==null)out((v.peeled||s.sha)+'\trefs/tags/'+t+'^{}\n')}} finish() + const t=a[3].slice(10),v=s.tags[t]; if(v){if(v.raw)out(v.raw.replaceAll('$TAG',t));else{out((v.direct||'a'.repeat(40))+'\trefs/tags/'+t+'\n');if(v.peeled!==null)out((v.peeled||s.artifactSha)+'\trefs/tags/'+t+'^{}\n')}} finish() } - if(a[0]==='for-each-ref'){const t=a[2].slice(10),v=s.localTags[t];if(v)out((v.type||'tag')+'\t'+(v.peeled||s.sha)+'\n');finish()} + if(a[0]==='for-each-ref'){const t=a[2].slice(10),v=s.localTags[t];if(v)out((v.type||'tag')+'\t'+(v.peeled||s.artifactSha)+'\n');finish()} if(a[0]==='tag'){s.localTags[a[2]]={type:'tag',peeled:a[3]};finish()} - if(a[0]==='push'){if(s.pushExit)finish(s.pushExit);for(const r of a.slice(3)){const t=r.split(':')[0].slice(10);s.tags[t]={peeled:s.localTags[t].peeled}}finish()} + if(a[0]==='push'){ + const materialize=()=>{for(const r of a.slice(3)){const t=r.split(':')[0].slice(10);s.tags[t]={peeled:s.localTags[t].peeled}}} + if(s.pushMaterializesOnFailure){materialize();finish(s.pushExit||1)} + if(s.pushExit)finish(s.pushExit) + materialize();finish() + } finish(127) } if(cmd==='npm'){ - const n=a[1],field=a[2],v=s.npm[n],q=v[field==='versions'?'versionsQueue':'latestQueue'];let x=q&&q.length?q.shift():field==='versions'?v.versions:v.latest - if(q&&q.length===0){if(field==='versions')v.versions=x;else v.latest=x} + const n=a[1],field=a[2],v=s.npm[n] + const take=(key,fallback)=>{const q=v[key],x=q&&q.length?q.shift():fallback;if(q&&q.length===0){if(key==='versionsQueue')v.versions=x;if(key==='latestQueue')v.latest=x}return x} + let x + if(field==='versions')x=take('versionsQueue',v.versions) + else if(field==='dist-tags.latest')x=take('latestQueue',v.latest) + else if(field==='dist-tags'){ + const q=v.distTagsQueue + if(q&&q.length){x=q.shift();if(q.length===0&&x&&typeof x==='object'&&!x.exit&&!Object.hasOwn(x,'raw')){v.alpha=x.alpha;v.beta=x.beta;v.latest=x.latest}} + else{const latest=take('latestQueue',v.latest);x={alpha:v.alpha,beta:v.beta};if(latest!==undefined)x.latest=latest} + }else finish(127) if(x&&typeof x==='object'&&x.exit){process.stderr.write(x.stderr||'failure');finish(x.exit)} - if(x&&typeof x==='object'&&Object.hasOwn(x,'raw'))out(x.raw);else out(JSON.stringify(x)+(v.pretty?'\n':'\n'));finish() + if(x&&typeof x==='object'&&Object.hasOwn(x,'raw'))out(x.raw);else out(JSON.stringify(x)+'\n');finish() } if(cmd==='pnpm'){ - const names=a[3].slice(11).split(','),count=s.publishSubset??names.length;for(const n of names.slice(0,count)){const v=s.expected[n],old=s.npm[n];old.versions=[v];old.latest=v;if(old.delayedVersions){old.versions=[];old.versionsQueue=Array(old.delayedVersions).fill([]).concat([[v]])}if(old.delayedLatest){old.latest='alpha';old.latestQueue=Array(old.delayedLatest).fill('alpha').concat(v)}} finish(s.publishExit||0) + s.publishEnvironment={ignoreScripts:process.env.NPM_CONFIG_IGNORE_SCRIPTS,inheritedSentinel:process.env.FINALIZE_ENV_SENTINEL} + const projects=a[3].slice(11).split(','),count=s.publishSubset??projects.length + for(const project of projects.slice(0,count)){const n=s.projectPackages[project],v=s.expected[n],old=s.npm[n];old.versions=[v];old.latest=v;if(old.delayedVersions){old.versions=[];old.versionsQueue=Array(old.delayedVersions).fill([]).concat([[v]])}if(old.delayedLatest){old.latest='0.0.1';old.latestQueue=Array(old.delayedLatest).fill('0.0.1').concat(v)}} + finish(s.publishExit||0) } finish(127)` -function load(file) { return JSON.parse(readFileSync(file, "utf8")) } -function save(file, value) { writeFileSync(file, JSON.stringify(value)) } -function mutations(state) { return state.log.filter(([c, a]) => c === "pnpm" || (c === "git" && (a === "tag" || a === "push")) || (c === "http" && a === "POST")) } +function load(file) { + return JSON.parse(readFileSync(file, "utf8")) +} +function save(file, value) { + writeFileSync(file, JSON.stringify(value)) +} +function mutations(state) { + // Fetch is a local synchronization/read operation: it updates remote-tracking state but cannot publish anything. + return state.log.filter( + ([command, operation]) => + command === "pnpm" || + (command === "git" && (operation === "tag" || operation === "push")) || + (command === "http" && operation === "POST"), + ) +} +function addArtifactFiles(gitFiles, commit) { + gitFiles[`${commit}:CHANGELOG.md`] = "# Changelog\n" + gitFiles[`${commit}:nx.json`] = { release: { projects: catalog.map(([, root]) => root) } } + for (const [project, root, beta, version] of catalog) { + gitFiles[`${commit}:${root}/project.json`] = { name: project } + gitFiles[`${commit}:${root}/package.json`] = { name: project, version } + gitFiles[`${commit}^1:${root}/package.json`] = { name: project, version: beta } + } +} -async function world(mode = "absent") { - const cwd = mkdtempSync(join(tmpdir(), "stable-finalize-")), bin = join(cwd, "bin"), stateFile = join(cwd, "state.json") - mkdirSync(bin); writeFileSync(join(bin, "fake.cjs"), fake); chmodSync(join(bin, "fake.cjs"), 0o755) - for (const command of ["git", "npm", "pnpm"]) symlinkSync("fake.cjs", join(bin, command)) - symlinkSync(process.execPath, join(bin, "node")) - const expected = {}, npm = {}, tags = {}, releases = {} - for (const [name, path, version] of records) { - mkdirSync(join(cwd, path, ".."), { recursive: true }); writeFileSync(join(cwd, path), JSON.stringify({ name, version })) - expected[name] = version; npm[name] = { versions: mode === "exact" ? [version] : [], latest: mode === "exact" ? version : "alpha", alpha: "alpha-sentinel", beta: "beta-sentinel" } - if (mode === "exact") { const tag = `${name}@${version}`; tags[tag] = { peeled: sha }; releases[tag] = { tag_name: tag, draft: false, prerelease: false } } +async function discardWorld({ cwd, server }) { + try { + if (server?.listening) await new Promise((resolve) => server.close(resolve)) + } finally { + await rm(cwd, { recursive: true, force: true }) } - save(stateFile, { sha, head: sha, origin: sha, expected, npm, tags, releases, localTags: {}, log: [] }) - const server = createServer((request, response) => { - const state = load(stateFile), method = request.method, path = request.url; state.log.push(["http", method, path]) - const send = (status, body = "") => { save(stateFile, state); response.writeHead(status, { "content-type": "application/json" }); response.end(typeof body === "string" ? body : JSON.stringify(body)) } - if (method === "GET") { - const tag = decodeURIComponent(path.split("/releases/tags/")[1] || ""), configured = state.ghReadStatus - if (configured) return send(configured, { message: "configured" }) - return state.releases[tag] ? send(200, state.releases[tag]) : send(404, { message: "not found" }) + assert.equal(existsSync(cwd), false) +} + +async function world(mode = "absent") { + const cwd = mkdtempSync(join(tmpdir(), "stable-finalize-")), + bin = join(cwd, "bin"), + stateFile = join(cwd, "state.json") + let server + try { + mkdirSync(bin) + writeFileSync(join(bin, "fake.cjs"), fake) + chmodSync(join(bin, "fake.cjs"), 0o755) + for (const command of ["git", "npm", "pnpm"]) symlinkSync("fake.cjs", join(bin, command)) + symlinkSync(process.execPath, join(bin, "node")) + const expected = {}, + npm = {}, + tags = {}, + releases = {}, + projectPackages = {}, + gitFiles = {} + addArtifactFiles(gitFiles, sha) + addArtifactFiles(gitFiles, historicalSha) + for (const [project, path, version, betaVersion] of records) { + mkdirSync(join(cwd, path, ".."), { recursive: true }) + writeFileSync(join(cwd, path), JSON.stringify({ name: project, version })) + projectPackages[project] = project + expected[project] = version + npm[project] = { + versions: mode === "exact" ? [betaVersion, version] : ["0.0.1", betaVersion], + latest: mode === "exact" ? version : "0.0.1", + alpha: "alpha-sentinel", + beta: betaVersion, + } + if (mode === "exact") { + const tag = `${project}@${version}` + tags[tag] = { peeled: sha } + releases[tag] = { tag_name: tag, draft: false, prerelease: false } + } } - let body = ""; request.on("data", x => body += x); request.on("end", () => { - const value = JSON.parse(body), tag = value.tag_name, status = state.ghCreateStatus || 201 - if (state.ghCreateMaterializes !== false) state.releases[tag] = { tag_name: tag, draft: false, prerelease: false } - send(status, status === 422 ? { message: "already exists" } : state.releases[tag]) + const changedPaths = ["CHANGELOG.md", ...records.map(([, path]) => path)].sort() + save(stateFile, { + sha, + artifactSha: sha, + parentSha, + treeSha, + head: sha, + origin: sha, + expected, + npm, + tags, + releases, + localTags: {}, + projectPackages, + gitFiles, + changedPaths, + log: [], }) - }) - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)) - return { cwd, bin, stateFile, server, api: `http://127.0.0.1:${server.address().port}` } + server = createServer((request, response) => { + const state = load(stateFile), + method = request.method, + path = request.url + state.log.push(["http", method, path]) + const send = (status, body = "") => { + save(stateFile, state) + response.writeHead(status, { "content-type": "application/json" }) + response.end(typeof body === "string" ? body : JSON.stringify(body)) + } + if (method === "GET") { + const tag = decodeURIComponent(path.split("/releases/tags/")[1] || ""), + configured = state.ghReadStatus + if (configured) return send(configured, { message: "configured" }) + return state.releases[tag] ? send(200, state.releases[tag]) : send(404, { message: "not found" }) + } + let body = "" + request.on("data", (chunk) => (body += chunk)) + request.on("end", () => { + const value = JSON.parse(body), + tag = value.tag_name, + status = state.ghCreateStatus || 201 + if (state.ghCreateMaterializes !== false) + state.releases[tag] = { tag_name: tag, draft: false, prerelease: false } + save(stateFile, state) + if (state.ghCreateResponseLoss) return response.destroy() + send(status, status === 422 ? { message: "already exists" } : state.releases[tag]) + }) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + return { cwd, bin, stateFile, server, api: `http://127.0.0.1:${server.address().port}` } + } catch (error) { + await discardWorld({ cwd, server }) + throw error + } } -async function run(w, args = [], environment = {}) { - return await new Promise(resolve => { - const child = spawn(process.execPath, [script, ...args], { cwd: w.cwd, env: { PATH: w.bin, EXPECTED_SHA: sha, ARTIFACT_SHA: "", NPM_READ_DELAY_MS: "0", FINALIZE_COMMAND_TIMEOUT_MS: "5000", GITHUB_API_URL: w.api, GITHUB_REPOSITORY: "owner/repo", GITHUB_TOKEN: "fake", FAKE_STATE: w.stateFile, ...environment } }) - let stdout = "", stderr = ""; child.stdout.on("data", x => stdout += x); child.stderr.on("data", x => stderr += x); child.on("close", status => resolve({ status, stdout, stderr })) +async function run(world, args = [], environment = {}) { + return await new Promise((resolve) => { + const child = spawn(process.execPath, [script, ...args], { + cwd: world.cwd, + env: { + PATH: world.bin, + EXPECTED_SHA: sha, + ARTIFACT_SHA: "", + PROJECTS: selectedProjects, + GITHUB_ACTIONS: "true", + NPM_READ_DELAY_MS: "0", + FINALIZE_COMMAND_TIMEOUT_MS: "5000", + GITHUB_API_URL: world.api, + GITHUB_REPOSITORY: "owner/repo", + GITHUB_TOKEN: "fake", + FAKE_STATE: world.stateFile, + ...environment, + }, + }) + let stdout = "", + stderr = "" + child.stdout.on("data", (chunk) => (stdout += chunk)) + child.stderr.on("data", (chunk) => (stderr += chunk)) + child.on("close", (status) => resolve({ status, stdout, stderr })) }) } async function scenario(t, name, setup, verify, mode = "exact", args = [], environment = {}) { - await t.test(name, async () => { const w = await world(mode); try { const state = load(w.stateFile); await setup(state, w); save(w.stateFile, state); const result = await run(w, args, environment); await verify(result, load(w.stateFile), w) } finally { await new Promise(resolve => w.server.close(resolve)) } }) + await t.test(name, async () => { + const fixture = await world(mode) + try { + const state = load(fixture.stateFile) + await setup(state, fixture) + save(fixture.stateFile, state) + const result = await run(fixture, args, environment) + await verify(result, load(fixture.stateFile), fixture) + } finally { + await discardWorld(fixture) + } + }) +} +function exactState(state) { + assert.equal(Object.keys(state.tags).length, records.length) + assert.equal(Object.keys(state.releases).length, records.length) + for (const [project, , version, betaVersion] of records) { + assert.ok(state.npm[project].versions.includes(version)) + assert.equal(state.npm[project].latest, version) + assert.equal(state.npm[project].alpha, "alpha-sentinel") + assert.equal(state.npm[project].beta, betaVersion) + } +} +function historicalTags(state) { + state.artifactSha = historicalSha + for (const [project, , version] of records) state.tags[`${project}@${version}`] = { peeled: historicalSha } +} +function mergeArtifact(state) { + state.commitLine = `${sha} ${parentSha} ${secondParentSha}` + state.commitLines = { [secondParentSha]: `${secondParentSha} ${parentSha}` } } -function exactState(state) { assert.equal(Object.keys(state.tags).length, 7); assert.equal(Object.keys(state.releases).length, 7); for (const [n,,v] of records) { assert.deepEqual(state.npm[n].versions, [v]); assert.equal(state.npm[n].latest, v); assert.equal(state.npm[n].alpha, "alpha-sentinel"); assert.equal(state.npm[n].beta, "beta-sentinel") } } -function historicalTags(state) { for (const [name,,version] of records) state.tags[`${name}@${version}`] = { peeled: historicalSha } } function workflowPreflightInvocation() { - const match = stableWorkflow.match(/^[ \t]*- name: 🔎 PREFLIGHT exact stable artifacts\n([\s\S]*?)(?=^[ \t]*- name:)/m) + const match = stableWorkflow.match( + /^[ \t]*- name: 🔎 PREFLIGHT exact stable artifacts\n([\s\S]*?)(?=^[ \t]*- name:)/m, + ) assert.ok(match, "stable workflow preflight step") + assert.match(match[1], /PROJECTS:\s*\$\{\{ needs\.validate\.outputs\.projects \}\}/) const commands = [...match[1].matchAll(/^[ \t]*run:\s*(.+)$/gm)].map((entry) => entry[1].trim()) assert.deepEqual(commands, ["bash scripts/release-finalize-stable.sh --preflight --json"]) return { args: commands[0].split(/\s+/).slice(2), source: match[1] } } const scenarioNames = [] -test("hermetic Node CLI matrix", { timeout: 120_000 }, async t => { - const add = async (...args) => { scenarioNames.push(args[0]); await scenario(t, ...args) } - await add("all exact replay has zero mutation", async()=>{}, (r,s)=>{assert.equal(r.status,0,r.stderr);assert.deepEqual(mutations(s),[])}) - await add("same-SHA all absent publishes normally", async()=>{}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s);const push=s.log.find(x=>x[0]==="git"&&x[1]==="push");assert.deepEqual(push.slice(1,4),["push","--atomic","origin"]);assert.equal(s.log.find(x=>x[0]==="pnpm")[4],`--projects=${records.map(x=>x[0]).join(",")}`)}, "absent") - await add("historical all-existing artifacts succeed with zero mutation", async s=>historicalTags(s), (r,s)=>{assert.equal(r.status,0,r.stderr);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha}) - await add("historical missing tag fails before mutation", async s=>{historicalTags(s);delete s.tags[`${records[0][0]}@${records[0][2]}`]}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/historical replay requires exact existing/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha}) - await add("historical missing Release fails before mutation", async s=>{historicalTags(s);delete s.releases[`${records[0][0]}@${records[0][2]}`]}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/historical replay requires exact existing/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha}) - await add("historical missing npm version fails before mutation", async s=>{historicalTags(s);s.npm[records[0][0]].versions=[]}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/historical replay requires exact existing/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha}) - await add("historical latest mismatch fails before mutation", async s=>{historicalTags(s);s.npm[records[0][0]].latest="alpha"}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/permanent latest divergence/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha}) - await add("wrong artifact SHA fails before mutation", async s=>historicalTags(s), (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/tag state is divergent/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:"f".repeat(40)}) - await add("malformed artifact SHA fails closed independently", async()=>{}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/full lowercase artifact SHA/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:"not-a-sha"}) - await add("malformed expected SHA fails closed independently", async s=>historicalTags(s), (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/full lowercase expected SHA/);assert.deepEqual(mutations(s),[])}, "exact", [], {EXPECTED_SHA:"not-a-sha",ARTIFACT_SHA:historicalSha}) - for (const [index] of records.entries()) await add(`tag partial subset ${index+1} replays`, async s=>{for(const [n,,v] of records.slice(0,index+1))s.tags[`${n}@${v}`]={peeled:sha}}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") - for (const [index] of records.entries()) await add(`release partial subset ${index+1} replays`, async s=>{for(const [n,,v] of records)s.tags[`${n}@${v}`]={peeled:sha};for(const [n,,v] of records.slice(0,index+1))s.releases[`${n}@${v}`]={tag_name:`${n}@${v}`,draft:false,prerelease:false}}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") - for (const [index] of records.entries()) await add(`npm partial subset ${index+1} replays`, async s=>{for(const [n,,v] of records){s.tags[`${n}@${v}`]={peeled:sha};s.releases[`${n}@${v}`]={tag_name:`${n}@${v}`,draft:false,prerelease:false}}for(const [n,,v] of records.slice(0,index+1)){s.npm[n].versions=[v];s.npm[n].latest=v}}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") - for (const subset of [1,3,6]) await add(`publish nonzero after subset ${subset} then replay`, async s=>{s.publishSubset=subset;s.publishExit=42}, async(r,s,w)=>{assert.notEqual(r.status,0);delete s.publishExit;delete s.publishSubset;save(w.stateFile,s);const replay=await run(w);assert.equal(replay.status,0,replay.stderr);exactState(load(w.stateFile))}, "absent") - for (const [format,value] of [["compact",[records[0][2]]],["pretty",{raw:`[\n "${records[0][2]}"\n]\n`}],["scalar",records[0][2]]]) await add(`npm ${format} versions JSON`, async s=>{s.npm[records[0][0]].versionsQueue=[value]}, (r)=>assert.equal(r.status,0,r.stderr)) - await add("npm delayed latest converges", async s=>{s.npm[records[0][0]].latestQueue=["beta","beta",records[0][2]]}, r=>assert.equal(r.status,0,r.stderr)) - await add("post-publish delayed version visibility converges", async s=>{s.npm[records[0][0]].delayedVersions=2}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") - await add("post-publish delayed latest converges", async s=>{s.npm[records[0][0]].delayedLatest=2}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") - await add("failed atomic push materializes no refs and replay reuses local tags", async s=>{s.pushExit=1}, async(r,s,w)=>{assert.notEqual(r.status,0);assert.equal(Object.keys(s.tags).length,0);assert.equal(Object.keys(s.localTags).length,7);delete s.pushExit;save(w.stateFile,s);const replay=await run(w);assert.equal(replay.status,0,replay.stderr);exactState(load(w.stateFile))}, "absent") - for (const [name,spec] of [["null",null],["empty",{raw:""}],["truncated",{raw:"[\"1.0"}],["object",{}],["mixed",[records[0][2],3]],["DNS",{exit:1,stderr:"ENOTFOUND"}],["auth",{exit:1,stderr:"E401"}],["rate",{exit:1,stderr:"E429"}],["5xx",{exit:1,stderr:"E503"}],["E404",{exit:1,stderr:"E404"}]]) await add(`npm ${name} is unknown and never publishes`, async s=>{s.npm[records[0][0]].versionsQueue=Array(6).fill(spec)}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) - for (const status of [401,403,429,500,503]) await add(`GitHub ${status} read is unknown`, async s=>{s.ghReadStatus=status}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) - await add("GitHub 404 is proven absent", async s=>{delete s.releases[`${records[0][0]}@${records[0][2]}`]}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}) - await add("GitHub 422 create reconciles materialized exact release", async s=>{s.ghCreateStatus=422}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") - await add("GitHub 422 without exact state fails", async s=>{s.ghCreateStatus=422;s.ghCreateMaterializes=false}, (r)=>assert.notEqual(r.status,0), "absent") - for (const [name,raw] of [["lightweight",`${"a".repeat(40)}\trefs/tags/$TAG\n`],["malformed","garbage\n"],["wrong SHA",`${"a".repeat(40)}\trefs/tags/$TAG\n${"f".repeat(40)}\trefs/tags/$TAG^{}\n`],["duplicate",`${"a".repeat(40)}\trefs/tags/$TAG\n${"b".repeat(40)}\trefs/tags/$TAG\n${sha}\trefs/tags/$TAG^{}\n`]]) await add(`tag ${name} fails closed`, async s=>{s.tags[`${records[0][0]}@${records[0][2]}`]={raw}}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}, "absent") - for (const [name,value] of [["lightweight",{type:"commit",peeled:sha}],["wrong SHA",{type:"tag",peeled:"f".repeat(40)}]]) await add(`local tag ${name} fails before mutation`, async s=>{s.localTags[`${records[0][0]}@${records[0][2]}`]=value}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}, "absent") - await add("manifest name mismatch fails before mutation", async(s,w)=>writeFileSync(join(w.cwd,records[0][1]),JSON.stringify({name:"wrong",version:records[0][2]})), (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) - await add("manifest version mismatch fails before mutation", async(s,w)=>writeFileSync(join(w.cwd,records[0][1]),JSON.stringify({name:records[0][0],version:"9.9.9"})), (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) - await add("EXPECTED_SHA controls HEAD", async s=>{s.head="f".repeat(40)}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) - await add("EXPECTED_SHA controls origin", async s=>{s.origin="f".repeat(40)}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) +test("hermetic arbitrary-subset Node CLI matrix", { timeout: 120_000 }, async (t) => { + const add = async (...args) => { + scenarioNames.push(args[0]) + await scenario(t, ...args) + } + await add( + "single-parent squash or single-commit rebase future subset exact replay locally synchronizes then performs zero tag, Release, or npm mutation", + async () => {}, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(mutations(state), []) + assert.ok( + state.log.some( + (call) => call[0] === "git" && call[1] === "rev-list" && call.includes("--parents") && call.at(-1) === sha, + ), + ) + assert.ok( + state.log.some( + (call) => call[0] === "git" && call[1] === "diff" && call.includes(`${sha}^1`) && call.at(-1) === sha, + ), + ) + }, + ) + await add( + "same-SHA future subset publishes only normalized reviewed projects", + async () => {}, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + exactState(state) + const status = state.log.find((call) => call[0] === "git" && call[1] === "status") + assert.deepEqual(status, ["git", "status", "--porcelain=v1", "--untracked-files=all"]) + const publish = state.log.find((call) => call[0] === "pnpm") + assert.equal(publish[4], `--projects=${selectedProjects}`) + }, + "absent", + ) + await add( + "publish child disables lifecycle scripts while preserving inherited environment", + async () => {}, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(state.publishEnvironment, { + ignoreScripts: "true", + inheritedSentinel: "preserved", + }) + exactState(state) + }, + "absent", + [], + { NPM_CONFIG_IGNORE_SCRIPTS: "false", FINALIZE_ENV_SENTINEL: "preserved" }, + ) + await add( + "exact stable replay does not require beta version or beta tag", + async (state) => { + for (const [project, , version] of records) { + state.npm[project].versions = [version] + delete state.npm[project].beta + } + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(mutations(state), []) + }, + ) + await add( + "stable absence with exact reviewed beta provenance publishes", + async () => {}, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + exactState(state) + }, + "absent", + ) + await add( + "stable absence with no latest publishes", + async (state) => { + for (const [project] of records) delete state.npm[project].latest + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + exactState(state) + }, + "absent", + ) + await add( + "prerelease latest strictly below stable target publishes", + async (state) => { + state.npm[records[0][0]].latest = records[0][3] + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + exactState(state) + }, + "absent", + ) + await add( + "historical all-existing artifacts perform zero tag, Release, or npm mutation", + async (state) => historicalTags(state), + (result, state) => { + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(mutations(state), []) + }, + "exact", + [], + { ARTIFACT_SHA: historicalSha }, + ) + await add( + "historical all-exact replay returns before checking an advanced current master manifest", + async (state, fixture) => { + historicalTags(state) + for (const [project, path] of records) { + const currentManifest = { name: project, version: "99.0.0", scripts: { build: "current-only" } } + state.gitFiles[`${sha}:${path}`] = currentManifest + writeFileSync(join(fixture.cwd, path), JSON.stringify(currentManifest)) + } + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(mutations(state), []) + assert.equal( + state.log.some((call) => call[0] === "git" && call[1] === "status"), + false, + ) + }, + "exact", + [], + { ARTIFACT_SHA: historicalSha }, + ) + await add( + "historical ancestor PREFLIGHT checks ancestry before artifact and registry reads", + async (state) => historicalTags(state), + (result, state) => { + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(mutations(state), []) + const ancestry = state.log.findIndex((call) => call[0] === "git" && call[1] === "merge-base") + const artifactRead = state.log.findIndex((call) => call[0] === "git" && call[1] === "show") + const registryRead = state.log.findIndex((call) => call[0] === "npm") + assert.ok(ancestry >= 0 && ancestry < artifactRead && ancestry < registryRead) + }, + "exact", + ["--preflight"], + { ARTIFACT_SHA: historicalSha }, + ) + await add( + "historical rebased non-ancestor fails before PREFLIGHT verification", + async (state) => { + historicalTags(state) + state.ancestor = false + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /ancestor/) + assert.deepEqual(mutations(state), []) + assert.equal( + state.log.some((call) => call[0] === "git" && call[1] === "show"), + false, + ) + assert.equal( + state.log.some((call) => call[0] === "npm" || call[0] === "http"), + false, + ) + }, + "exact", + ["--preflight"], + { ARTIFACT_SHA: historicalSha }, + ) + await add( + "historical ancestry command ambiguity fails closed", + async (state) => { + historicalTags(state) + state.ancestorExit = 128 + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.deepEqual(mutations(state), []) + assert.equal( + state.log.some((call) => call[0] === "git" && call[1] === "show"), + false, + ) + }, + "exact", + ["--preflight"], + { ARTIFACT_SHA: historicalSha }, + ) + await add( + "valid two-parent merge with a single generated release commit based on its first parent succeeds", + async (state) => mergeArtifact(state), + (result, state) => { + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(mutations(state), []) + }, + ) + await add( + "octopus reviewed artifact fails closed", + async (state) => { + state.commitLine = `${sha} ${parentSha} ${secondParentSha} ${thirdParentSha}` + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /single-parent commit or exact two-parent merge/) + assert.deepEqual(mutations(state), []) + }, + ) + await add( + "merge second parent not based directly on first parent fails closed", + async (state) => { + mergeArtifact(state) + state.commitLines[secondParentSha] = `${secondParentSha} ${thirdParentSha}` + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /based directly on first parent/) + assert.deepEqual(mutations(state), []) + }, + ) + await add( + "merge second parent with multiple commits fails closed", + async (state) => { + mergeArtifact(state) + state.commitLines[secondParentSha] = `${secondParentSha} ${parentSha} ${thirdParentSha}` + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /single commit based directly on first parent/) + assert.deepEqual(mutations(state), []) + }, + ) + await add( + "merge tree differing from generated second parent fails closed", + async (state) => { + mergeArtifact(state) + state.artifactTreeSha = "8888888888888888888888888888888888888888" + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /merge tree must exactly match/) + assert.deepEqual(mutations(state), []) + }, + ) + await add( + "merge aggregate first-parent diff rejects an extra path", + async (state) => { + mergeArtifact(state) + state.changedPaths.push("README.md") + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /unexpected reviewed path/) + assert.deepEqual(mutations(state), []) + }, + ) + await add( + "merge aggregate first-parent diff rejects an invalid manifest transition", + async (state) => { + mergeArtifact(state) + state.gitFiles[`${sha}:${records[0][1]}`].version = "4.7.1" + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /beta-to-stable transition/) + assert.deepEqual(mutations(state), []) + }, + ) + await add( + "malformed reviewed commit shape fails closed", + async (state) => { + state.commitLine = "malformed history" + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /commit shape is invalid/) + assert.deepEqual(mutations(state), []) + }, + ) + await add( + "historical split release commits are not reconstructed by history search", + async (state) => { + historicalTags(state) + state.changedPaths = ["CHANGELOG.md", records[0][1]] + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /requested projects do not exactly match/) + assert.deepEqual(mutations(state), []) + assert.equal( + state.log.some((call) => call[0] === "git" && call[1] === "log"), + false, + ) + }, + "exact", + ["--preflight"], + { ARTIFACT_SHA: historicalSha }, + ) + await add( + "historical missing tag fails before mutation", + async (state) => { + historicalTags(state) + delete state.tags[`${records[0][0]}@${records[0][2]}`] + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /historical replay requires exact existing/) + assert.deepEqual(mutations(state), []) + }, + "exact", + [], + { ARTIFACT_SHA: historicalSha }, + ) + for (const [index] of records.entries()) + await add( + `tag partial subset ${index + 1} replays`, + async (state) => { + for (const [project, , version] of records.slice(0, index + 1)) + state.tags[`${project}@${version}`] = { peeled: sha } + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + exactState(state) + }, + "absent", + ) + for (const [index] of records.entries()) + await add( + `release partial subset ${index + 1} replays`, + async (state) => { + for (const [project, , version] of records) state.tags[`${project}@${version}`] = { peeled: sha } + for (const [project, , version] of records.slice(0, index + 1)) + state.releases[`${project}@${version}`] = { + tag_name: `${project}@${version}`, + draft: false, + prerelease: false, + } + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + exactState(state) + }, + "absent", + ) + for (const [index] of records.entries()) + await add( + `npm partial subset ${index + 1} replays`, + async (state) => { + for (const [project, , version] of records) { + state.tags[`${project}@${version}`] = { peeled: sha } + state.releases[`${project}@${version}`] = { + tag_name: `${project}@${version}`, + draft: false, + prerelease: false, + } + } + for (const [project, , version] of records.slice(0, index + 1)) { + state.npm[project].versions = [version] + state.npm[project].latest = version + } + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + exactState(state) + }, + "absent", + ) + await add( + "publish nonzero after subset then replay", + async (state) => { + state.publishSubset = 1 + state.publishExit = 42 + }, + async (result, state, fixture) => { + assert.notEqual(result.status, 0) + delete state.publishExit + delete state.publishSubset + save(fixture.stateFile, state) + const replay = await run(fixture) + assert.equal(replay.status, 0, replay.stderr) + exactState(load(fixture.stateFile)) + }, + "absent", + ) + await add( + "atomic push response loss reconciles exact remote refs", + async (state) => { + state.pushExit = 1 + state.pushMaterializesOnFailure = true + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + exactState(state) + }, + "absent", + ) + await add( + "failed atomic push materializes no refs and replay reuses local tags", + async (state) => { + state.pushExit = 1 + }, + async (result, state, fixture) => { + assert.notEqual(result.status, 0) + assert.equal(Object.keys(state.tags).length, 0) + assert.equal(Object.keys(state.localTags).length, records.length) + delete state.pushExit + save(fixture.stateFile, state) + const replay = await run(fixture) + assert.equal(replay.status, 0, replay.stderr) + exactState(load(fixture.stateFile)) + }, + "absent", + ) + await add( + "GitHub create response loss reconciles exact Release", + async (state) => { + state.ghCreateResponseLoss = true + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + exactState(state) + }, + "absent", + ) + await add( + "GitHub 422 create reconciles materialized exact release", + async (state) => { + state.ghCreateStatus = 422 + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + exactState(state) + }, + "absent", + ) + await add( + "GitHub 422 without exact state fails", + async (state) => { + state.ghCreateStatus = 422 + state.ghCreateMaterializes = false + }, + (result) => assert.notEqual(result.status, 0), + "absent", + ) + for (const [format, value] of [ + ["array", [records[0][2]]], + ["scalar", records[0][2]], + ]) + await add( + `npm ${format} versions JSON`, + async (state) => { + state.npm[records[0][0]].versionsQueue = [value] + }, + (result) => assert.equal(result.status, 0, result.stderr), + ) + await add( + "npm delayed latest converges", + async (state) => { + state.npm[records[0][0]].latestQueue = [records[0][3], records[0][3], records[0][2]] + }, + (result) => assert.equal(result.status, 0, result.stderr), + ) + await add( + "missing reviewed beta version blocks stable publication", + async (state) => { + const [project, , , betaVersion] = records[0] + state.npm[project].versions = state.npm[project].versions.filter((version) => version !== betaVersion) + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + await add( + "missing beta dist-tag blocks stable publication", + async (state) => { + delete state.npm[records[0][0]].beta + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + await add( + "different beta dist-tag blocks stable publication", + async (state) => { + const project = records[0][0], + other = "4.7.0-beta.11" + state.npm[project].versions.push(other) + state.npm[project].beta = other + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + await add( + "beta provenance loss before npm publish blocks pnpm mutation", + async (state) => { + const [project, , , betaVersion] = records[0] + const good = { alpha: "alpha-sentinel", beta: betaVersion, latest: "0.0.1" } + const bad = { alpha: "alpha-sentinel", beta: "4.7.0-beta.11", latest: "0.0.1" } + state.npm[project].distTagsQueue = [good, ...Array(6).fill(bad)] + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal( + state.log.some((call) => call[0] === "pnpm"), + false, + ) + }, + "absent", + ) + await add( + "concurrent exact stable appearance is omitted from missing-only publish", + async (state) => { + const [project, , version, betaVersion] = records[0] + state.npm[project].versionsQueue = [ + ["0.0.1", betaVersion], + [betaVersion, version], + ] + state.npm[project].distTagsQueue = [ + { alpha: "alpha-sentinel", beta: betaVersion, latest: "0.0.1" }, + { alpha: "alpha-sentinel", beta: betaVersion, latest: version }, + ] + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + const publish = state.log.find((call) => call[0] === "pnpm") + assert.equal(publish[4], `--projects=${records[1][0]}`) + exactState(state) + }, + "absent", + ) + await add( + "latest tag absent from versions list blocks publication", + async (state) => { + state.npm[records[0][0]].latest = records[0][2] + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + await add( + "higher latest blocks publication from moving latest backward", + async (state) => { + const project = records[0][0], + higher = "4.7.1" + state.npm[project].versions.push(higher) + state.npm[project].latest = higher + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + await add( + "latest not present in versions is inconsistent", + async (state) => { + state.npm[records[0][0]].latest = "1.0.0" + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + await add( + "latest SemVer with a leading zero fails closed", + async (state) => { + const project = records[0][0], + malformed = "01.0.0" + state.npm[project].versions.push(malformed) + state.npm[project].latest = malformed + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + await add( + "malformed historical version list entry fails closed", + async (state) => { + state.npm[records[0][0]].versions.push("1.02.3") + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + await add( + "duplicate npm versions fail closed", + async (state) => { + state.npm[records[0][0]].versions.push(records[0][3]) + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + await add( + "malformed dist-tags JSON fails closed", + async (state) => { + state.npm[records[0][0]].distTagsQueue = Array(6).fill({ raw: "{" }) + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + await add( + "post-publish delayed version visibility converges", + async (state) => { + state.npm[records[0][0]].delayedVersions = 2 + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + exactState(state) + }, + "absent", + ) + await add( + "post-publish delayed latest converges", + async (state) => { + state.npm[records[0][0]].delayedLatest = 2 + }, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + exactState(state) + }, + "absent", + ) + for (const [name, spec] of [ + ["null", null], + ["empty", { raw: "" }], + ["truncated", { raw: '["1.0' }], + ["object", {}], + ["mixed", [records[0][2], 3]], + ["execution error", { exit: 1, stderr: "E503" }], + ]) + await add( + `npm ${name} is unknown and never publishes`, + async (state) => { + state.npm[records[0][0]].versionsQueue = Array(6).fill(spec) + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + ) + await add( + "GitHub non-200 read is unknown", + async (state) => { + state.ghReadStatus = 503 + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + ) + for (const [name, raw] of [ + ["lightweight", `${"a".repeat(40)}\trefs/tags/$TAG\n`], + ["malformed", "garbage\n"], + ["wrong SHA", `${"a".repeat(40)}\trefs/tags/$TAG\n${"f".repeat(40)}\trefs/tags/$TAG^{}\n`], + ["duplicate", `${"a".repeat(40)}\trefs/tags/$TAG\n${"b".repeat(40)}\trefs/tags/$TAG\n${sha}\trefs/tags/$TAG^{}\n`], + ]) + await add( + `tag ${name} fails closed`, + async (state) => { + state.tags[`${records[0][0]}@${records[0][2]}`] = { raw } + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + for (const [name, value] of [ + ["lightweight", { type: "commit", peeled: sha }], + ["wrong SHA", { type: "tag", peeled: "f".repeat(40) }], + ]) + await add( + `local tag ${name} fails before mutation`, + async (state) => { + state.localTags[`${records[0][0]}@${records[0][2]}`] = value + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + for (const [name, status] of [ + ["dirty tracked source", " M packages/future/nebula/src/index.ts\n"], + ["staged changes", "M packages/future/nebula/src/index.ts\n"], + ["untracked package file", "?? packages/future/nebula/src/generated.js\n"], + ]) + await add( + `${name} fails before mutation`, + async (state) => { + state.worktreeStatus = status + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /clean index and worktree/) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + for (const [name, manifest] of [ + ["altered on-disk selected manifest name", { name: "@future/imposter", version: records[0][2] }], + ["altered on-disk selected manifest version", { name: records[0][0], version: "99.0.0" }], + ]) + await add( + `${name} fails before mutation`, + async (state, fixture) => { + writeFileSync(join(fixture.cwd, records[0][1]), JSON.stringify(manifest)) + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /on-disk manifest/) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + await add( + "altered on-disk selected manifest dependency fails before mutation", + async (state, fixture) => { + const path = records[0][1], + reviewed = state.gitFiles[`${sha}:${path}`] + reviewed.dependencies = { "reviewed-dependency": "1.0.0" } + writeFileSync( + join(fixture.cwd, path), + JSON.stringify({ ...reviewed, dependencies: { "reviewed-dependency": "2.0.0" } }), + ) + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /on-disk manifest/) + assert.equal(mutations(state).length, 0) + }, + "absent", + ) + await add( + "EXPECTED_SHA controls HEAD", + async (state) => { + state.head = "f".repeat(40) + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + ) + await add( + "EXPECTED_SHA controls origin", + async (state) => { + state.origin = "f".repeat(40) + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.equal(mutations(state).length, 0) + }, + ) + + for (const [name, setup, environment, pattern] of [ + [ + "requested projects must include every reviewed manifest", + async () => {}, + { PROJECTS: records[0][0] }, + /requested projects do not exactly match reviewed manifest changes/, + ], + [ + "requested projects cannot include an unreviewed allowlisted project", + async () => {}, + { PROJECTS: [records[0][0], catalog[1][0], records[1][0]].sort().join(",") }, + /requested projects do not exactly match reviewed manifest changes/, + ], + [ + "duplicate requested projects fail closed", + async () => {}, + { PROJECTS: `${records[0][0]},${records[0][0]}` }, + /duplicate requested project/, + ], + [ + "non-release requested project fails closed", + async () => {}, + { PROJECTS: "@future/not-release" }, + /not in artifact release projects/, + ], + [ + "reviewed diff requires root changelog", + async (state) => { + state.changedPaths = state.changedPaths.filter((path) => path !== "CHANGELOG.md") + }, + {}, + /root CHANGELOG/, + ], + [ + "reviewed diff rejects extra path", + async (state) => { + state.changedPaths.push("README.md") + }, + {}, + /unexpected reviewed path/, + ], + [ + "reviewed diff rejects stable source", + async (state) => { + state.gitFiles[`${sha}^1:${records[0][1]}`].version = records[0][2] + }, + {}, + /beta-to-stable transition/, + ], + [ + "reviewed diff rejects a target other than beta base", + async (state) => { + state.gitFiles[`${sha}:${records[0][1]}`].version = "4.7.1" + }, + {}, + /beta-to-stable transition/, + ], + [ + "reviewed diff rejects package rename", + async (state) => { + state.gitFiles[`${sha}:${records[0][1]}`].name = "@future/renamed" + }, + {}, + /manifest identity/, + ], + ]) + await add( + name, + setup, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, pattern) + assert.deepEqual(mutations(state), []) + }, + "exact", + [], + environment, + ) + + await add( + "a changed-path changelog entry cannot substitute for an artifact changelog blob", + async (state) => { + assert.ok(state.changedPaths.includes("CHANGELOG.md")) + delete state.gitFiles[`${sha}:CHANGELOG.md`] + }, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /root CHANGELOG\.md to exist as a blob/) + assert.deepEqual(mutations(state), []) + assert.equal( + state.log.some((call) => call[0] === "npm" || call[0] === "http"), + false, + ) + }, + ) + + await add( + "FINALIZE refuses to run outside GitHub Actions", + async () => {}, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /GitHub Actions/) + assert.deepEqual(mutations(state), []) + }, + "absent", + [], + { GITHUB_ACTIONS: "" }, + ) const preflight = workflowPreflightInvocation() - assert.doesNotMatch(preflight.source, /NODE_AUTH_TOKEN|NPM_CONFIG_PROVENANCE|npm whoami|nx release publish|git (?:tag|push)|gh release (?:create|delete)/) - await add("workflow historical preflight JSON includes both SHAs and reads only", async s=>historicalTags(s), (r,s)=>{assert.equal(r.status,0,r.stderr);const output=JSON.parse(r.stdout);assert.equal(output.expectedSha,sha);assert.equal(output.artifactSha,historicalSha);assert.equal(mutations(s).length,0)}, "exact", preflight.args, {ARTIFACT_SHA:historicalSha}) + assert.doesNotMatch( + preflight.source, + /NODE_AUTH_TOKEN|NPM_CONFIG_PROVENANCE|npm whoami|nx release publish|git (?:tag|push)|gh release (?:create|delete)/, + ) + await add( + "PREFLIGHT locally synchronizes before SHA authorization and performs zero tag, Release, or npm mutation", + async () => {}, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + const output = JSON.parse(result.stdout) + assert.deepEqual(output.projects, selectedProjects.split(",")) + assert.equal(output.expectedSha, sha) + assert.equal(output.artifactSha, sha) + const fetch = state.log.findIndex( + (call) => + call[0] === "git" && call.slice(1).join(" ") === "fetch origin master:refs/remotes/origin/master --no-tags", + ) + const authorization = state.log.findIndex( + (call) => call[0] === "git" && call[1] === "rev-parse" && call[2] === "origin/master", + ) + assert.ok(fetch >= 0 && fetch < authorization) + assert.deepEqual(mutations(state), []) + }, + "exact", + preflight.args, + { GITHUB_ACTIONS: "" }, + ) assert.equal(new Set(scenarioNames).size, scenarioNames.length) }) -test("static command boundary keeps shell and destructive repairs out", () => { +test("importing with a nonexistent argv entry is inert", async () => { + const cwd = mkdtempSync(join(tmpdir(), "stable-finalize-import-")) + const previousEntry = process.argv[1] + const previousExitCode = process.exitCode + const previousStderrWrite = process.stderr.write + const stderr = [] + try { + process.argv[1] = join(cwd, "guaranteed-missing-entry.mjs") + process.stderr.write = (chunk) => { + stderr.push(String(chunk)) + return true + } + await import(`${new URL("release-finalize-stable.mjs", import.meta.url).href}?import-only=${Date.now()}`) + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(stderr, []) + assert.equal(process.exitCode, previousExitCode) + } finally { + process.argv[1] = previousEntry + process.exitCode = previousExitCode + process.stderr.write = previousStderrWrite + await rm(cwd, { recursive: true, force: true }) + } + assert.equal(existsSync(cwd), false) +}) + +test("a URL-significant executable path still enters the finalizer main module", async () => { + const cwd = mkdtempSync(join(tmpdir(), "stable-finalize-entry-")) + let result + try { + const entry = join(cwd, "release # %.mjs") + writeFileSync(entry, readFileSync(script)) + result = await new Promise((resolve) => { + const child = spawn(process.execPath, [entry], { + cwd, + env: { + ...process.env, + EXPECTED_SHA: "", + ARTIFACT_SHA: "", + PROJECTS: "", + }, + }) + let stdout = "", + stderr = "" + child.stdout.on("data", (chunk) => (stdout += chunk)) + child.stderr.on("data", (chunk) => (stderr += chunk)) + child.on("close", (status) => resolve({ status, stdout, stderr })) + }) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + + assert.equal(existsSync(cwd), false) + assert.notEqual(result.status, 0) + assert.equal(result.stdout, "") + assert.match(result.stderr, /FINALIZE requires full lowercase expected SHA/) +}) + +test("a URL-significant symlink enters the finalizer main module with preserved symlink identity", async () => { + const cwd = mkdtempSync(join(tmpdir(), "stable-finalize-symlink-entry-")) + let result + try { + const entry = join(cwd, "release # %.mjs") + symlinkSync(script, entry) + result = await new Promise((resolve) => { + const child = spawn(process.execPath, ["--preserve-symlinks-main", entry], { + cwd, + env: { + ...process.env, + EXPECTED_SHA: "", + ARTIFACT_SHA: "", + PROJECTS: "", + }, + }) + let stdout = "", + stderr = "" + child.stdout.on("data", (chunk) => (stdout += chunk)) + child.stderr.on("data", (chunk) => (stderr += chunk)) + child.on("close", (status) => resolve({ status, stdout, stderr })) + }) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + + assert.equal(existsSync(cwd), false) + assert.notEqual(result.status, 0) + assert.equal(result.stdout, "") + assert.match(result.stderr, /FINALIZE requires full lowercase expected SHA/) +}) + +test("workflow FINALIZE step disables publication lifecycle scripts", () => { + const finalizeJob = stableWorkflow.match(/^ finalize:\n([\s\S]*?)(?=^ summary:)/m) + assert.ok(finalizeJob, "stable FINALIZE job") + const finalizeStep = finalizeJob[1].match(/^ - name: 🚀 FINALIZE exact stable artifacts\n([\s\S]*)$/m) + assert.ok(finalizeStep, "stable FINALIZE step") + assert.match(finalizeStep[1], /NPM_CONFIG_IGNORE_SCRIPTS:\s*true/) +}) + +test("static command and publication boundary removes historical truth", () => { const source = readFileSync(script, "utf8") assert.match(source, /spawn\(file, args, \{ shell: false/) + assert.match(source, /process\.env\.GITHUB_ACTIONS/) + assert.match(source, /function isMainModule\(\)/) + assert.match(source, /resolvedEntry = realpathSync\(entry\)/) + assert.match(source, /resolvedModule = realpathSync\(fileURLToPath\(import\.meta\.url\)\)/) + assert.match(source, /pathToFileURL\(resolvedEntry\)\.href === pathToFileURL\(resolvedModule\)\.href/) + assert.match(source, /run\("git", \["cat-file", "-t", `\$\{artifactSha\}:CHANGELOG\.md`\]\)/) + assert.equal(source.match(/\\u0000/g)?.length, 2) + assert.match(source, /artifactSha.*nx\.json|nx\.json.*artifactSha/s) + assert.doesNotMatch(source, /^const records\s*=\s*\[/m) + assert.doesNotMatch(source, /@effectify\/(?:hatchet|react-query|solid-query)|0\.5\.13|1\.1\.13/) assert.doesNotMatch(source, /execSync|spawnSync|shell: true|npm dist-tag|npm unpublish|release delete|tag", "-f/) }) diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index 5dfa1c32..57709180 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict" import { spawnSync } from "node:child_process" -import { readFileSync } from "node:fs" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" import test from "node:test" const read = (path) => { @@ -105,11 +107,11 @@ const extractSteps = (source) => { for (index += 1; index < lines.length; index += 1) { const command = lines[index] - step.source += `${command}\n` if (command.trim() && indentation(command) <= runIndent) { index -= 1 break } + step.source += `${command}\n` const trimmed = command.trim() if (trimmed && !trimmed.startsWith("#")) step.commands.push(trimmed) } @@ -177,7 +179,8 @@ const sensitiveShellViolations = (step, label) => { } for (const fn of functions) { const invoked = step.commands.some( - (command, index) => (index < fn.start || index >= fn.end) && new RegExp(`^${fn.name}(?:\\s|$)`).test(command), + (command, index) => + (index < fn.start || index >= fn.end) && new RegExp(`(?:^|\\$\\()${fn.name}(?:\\s|\\)|$)`).test(command), ) if (!invoked) violations.push(`${label} unused shell function ${fn.name}`) } @@ -226,7 +229,377 @@ const buildCommand = /^pnpm nx run-many -t build "--projects=\$PROJECTS" --paral const testCommand = /^pnpm nx run-many -t test "--projects=\$PROJECTS" --parallel=3 --passWithNoTests$/ const contractCommand = /^node --test scripts\/release-policy-contract\.test\.mjs$/ const releaseSubjectGuard = - '[[ "$HEAD_SUBJECT" == *"chore(release):"* || "$HEAD_SUBJECT" == *"[skip release]"* ]] || [ "$BETA_TRANSITIONS" -gt 0 ]' + 'if [[ "$HEAD_SUBJECT" == *"chore(release):"* || "$HEAD_SUBJECT" == *"[skip release]"* ]]; then' +const releaseManifestGuard = + 'if [ "$HAS_CHANGELOG" = "true" ] || [ "$INVALID_MANIFESTS" -gt 0 ] || [ "$BETA_TRANSITIONS" -gt 0 ] || [ "$MANIFEST_CHANGES" -ne "$BENIGN_MANIFEST_CHANGES" ]; then' +const exactBetaSuppressionGuard = + 'if [ "$HAS_CHANGELOG" = "true" ] && [ "$UNEXPECTED" = "false" ] && [ "$INVALID_MANIFESTS" = "0" ] && [ "$BETA_TRANSITIONS" -gt 0 ] && [ "$BETA_TRANSITIONS" -eq "$MANIFEST_CHANGES" ]; then' +const classificationFailClosedGuard = 'if [ "$CLASSIFICATION" != "prepare" ]; then' +const oldManifestCardinalityGuard = + "if ! printf '%s' \"$OLD_DOCUMENT\" | jq -e -s 'length == 1 and (.[0] | type == \"object\")' >/dev/null 2>&1 ||" +const newManifestCardinalityGuard = + "! printf '%s' \"$NEW_DOCUMENT\" | jq -e -s 'length == 1 and (.[0] | type == \"object\")' >/dev/null 2>&1; then" +const betaFinalizeExpectedShaGuard = '[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || {' +const stableFinalizeExpectedShaGuard = + "[[ \"$EXPECTED_SHA\" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::FINALIZE requires full lowercase expected_sha'; exit 1; }" +const stableTransitionVersionPattern = "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)-beta\\.(0|[1-9][0-9]*)$" + +const permissionEntries = (job) => { + const block = job.match(/^\s{4}permissions:\s*\n((?:\s{6}[A-Za-z-]+:\s*[^\n]+\n?)+)/m)?.[1] ?? "" + return [...block.matchAll(/^\s{6}([A-Za-z-]+):\s*([^\s#]+)\s*$/gm)].map(([, name, access]) => [name, access]) +} + +const hasExactPermissions = (job, expected) => { + const entries = permissionEntries(job) + return ( + entries.length === Object.keys(expected).length && + new Set(entries.map(([name]) => name)).size === entries.length && + entries.every(([name, access]) => expected[name] === access) + ) +} + +const checkoutPersistsCredentials = (job) => + extractSteps(job).some( + (step) => step.uses.startsWith("actions/checkout@") && !/persist-credentials:\s*false/.test(step.source), + ) + +const stableCapabilityViolations = (source) => { + const violations = [] + const jobs = Object.fromEntries( + ["validate", "prepare", "preflight", "finalize"].map((name) => [name, extractJob(source, name)]), + ) + for (const [name, job] of Object.entries(jobs)) if (!job) violations.push(`stable ${name} job`) + if (violations.length > 0) return violations + + for (const [name, expected] of [ + ["validate", { contents: "read" }], + ["prepare", { contents: "write" }], + ["preflight", { contents: "read" }], + ["finalize", { contents: "write", "id-token": "write" }], + ]) { + if (!hasExactPermissions(jobs[name], expected)) { + violations.push(`stable ${name} least privilege`) + } + if (checkoutPersistsCredentials(jobs[name])) violations.push(`stable ${name} persisted checkout credentials`) + } + + const validateSteps = extractSteps(jobs.validate) + for (const required of [contractCommand, buildCommand, testCommand]) { + if (!validateSteps.some((step) => step.commands.some((command) => required.test(command)))) { + violations.push(`stable validation ${String(required)}`) + } + } + if (/id-token:\s*write|contents:\s*write|NPM_TOKEN|NODE_AUTH_TOKEN|RELEASE_TOKEN/.test(jobs.validate)) { + violations.push("stable validation credential isolation") + } + for (const output of ["mode", "projects", "validated_sha", "expected_sha", "artifact_sha"]) { + if (!new RegExp(`^\\s{6}${output}:`, "m").test(jobs.validate)) violations.push(`stable validation ${output} output`) + } + + const prepareSteps = extractSteps(jobs.prepare) + const prepareCredentialSteps = prepareSteps.filter((step) => + /secrets\.|github\.token|GITHUB_TOKEN:|GH_TOKEN:|NODE_AUTH_TOKEN:/.test(step.source), + ) + if ( + prepareCredentialSteps.length !== 1 || + !prepareCredentialSteps[0].name.includes("Push protected stable branch") || + !prepareCredentialSteps[0].commands.some((command) => /^git .*push\b/.test(command)) + ) { + violations.push("stable PREPARE push-only credentials") + } + if ( + /id-token:\s*write|NODE_AUTH_TOKEN|NPM_CONFIG_PROVENANCE|nx release publish|npm publish|gh release/.test( + jobs.prepare, + ) + ) { + violations.push("stable PREPARE publication isolation") + } + requireCommandOrder( + violations, + jobs.prepare, + [ + /^git commit -m /, + /^RELEASE_SHA=\$\(git rev-parse HEAD\)$/, + /^PARENTS=\$\(git rev-list --parents -n 1 "\$RELEASE_SHA"\)$/, + /^if ! RELEASE_CHANGELOG_TYPE=\$\(git cat-file -t "\$RELEASE_SHA:CHANGELOG\.md" 2>\/dev\/null\)/, + /^git diff --name-only --no-renames "\$SOURCE_SHA" "\$RELEASE_SHA"/, + /^COMMITTED_DOCUMENT=\$\(git show "\$RELEASE_SHA:\$MANIFEST_PATH"\)$/, + /^test -z "\$\(git status --porcelain\)" \|\| \{ echo '::error::post-commit tree dirty'/, + /^test "\$\(git rev-parse origin\/master\)" = "\$SOURCE_SHA" \|\| \{ echo '::error::master moved before stable branch push'/, + /^git .*push origin "HEAD:refs\/heads\/\$BRANCH"/, + ], + "stable PREPARE post-commit revalidation before push", + ) + + if ( + /contents:\s*write|id-token:\s*write|NPM_TOKEN|NODE_AUTH_TOKEN|RELEASE_TOKEN|NPM_CONFIG_PROVENANCE/.test( + jobs.preflight, + ) + ) { + violations.push("stable PREFLIGHT read-only credentials") + } + + const finalizeSteps = extractSteps(jobs.finalize) + const finalizeCredentialSteps = finalizeSteps.filter((step) => + /secrets\.|github\.token|GITHUB_TOKEN:|GH_TOKEN:|NODE_AUTH_TOKEN:|NPM_CONFIG_PROVENANCE/.test(step.source), + ) + if ( + finalizeCredentialSteps.length !== 1 || + !finalizeCredentialSteps[0].name.includes("FINALIZE exact stable artifacts") + ) { + violations.push("stable FINALIZE step-only credentials") + } + if (!/^\s{4}environment:\s*stable-release\s*$/m.test(jobs.finalize)) { + violations.push("stable FINALIZE protected environment") + } + if ( + !finalizeSteps.some( + (step) => + step.name.includes("FINALIZE exact stable artifacts") && /NPM_CONFIG_IGNORE_SCRIPTS:\s*true/.test(step.source), + ) + ) { + violations.push("stable FINALIZE lifecycle-script environment") + } + if ( + finalizeSteps.some((step) => + step.commands.some( + (command) => + /(?:^|\s)(?:build|test)(?:\s|$)|nx (?:run|test)|npm whoami|nx release version/.test(command) || + (/^pnpm install\b/.test(command) && !/--ignore-scripts/.test(command)), + ), + ) + ) { + violations.push("stable FINALIZE lifecycle isolation") + } + return violations +} + +const classifierStartMarker = "# release-policy-classifier:start" +const classifierEndMarker = "# release-policy-classifier:end" +const classifierInvocation = "CLASSIFICATION=$(classify_push_shape)" + +const classifierStructureViolations = (source) => { + const violations = [] + const lines = source.split("\n") + const startIndexes = lines.flatMap((line, index) => (line.trim() === classifierStartMarker ? [index] : [])) + const endIndexes = lines.flatMap((line, index) => (line.trim() === classifierEndMarker ? [index] : [])) + const executable = commandEntries(source).map(({ command }) => command) + const declarations = executable.filter((command) => command === "classify_push_shape() {") + const invocations = executable.filter( + (command) => command !== "classify_push_shape() {" && /\bclassify_push_shape\b/.test(command), + ) + + if (startIndexes.length !== 1) violations.push("beta exactly one classifier start marker") + if (endIndexes.length !== 1) violations.push("beta exactly one classifier end marker") + if (declarations.length !== 1) violations.push("beta exactly one classifier declaration") + if (invocations.length !== 1 || invocations[0] !== classifierInvocation) { + violations.push("beta exactly one classifier invocation") + } + if (startIndexes.length === 1 && endIndexes.length === 1) { + const [start] = startIndexes + const [end] = endIndexes + if (end <= start) { + violations.push("beta classifier marker order") + } else { + const firstExecutable = lines + .slice(end + 1) + .map((line) => line.trim()) + .find((line) => line !== "" && !line.startsWith("#")) + if (firstExecutable !== classifierInvocation) { + violations.push("beta classifier invocation immediately follows end marker") + } + } + } + return violations +} + +const extractBetaPushClassifier = (source) => { + if (classifierStructureViolations(source).length > 0) return "" + const lines = source.split("\n") + const start = lines.findIndex((line) => line.trim() === classifierStartMarker) + const end = lines.findIndex((line) => line.trim() === classifierEndMarker) + return lines.slice(start + 1, end).join("\n") +} + +const extractRunScript = (step) => { + const lines = step?.source.split("\n") ?? [] + const runIndex = lines.findIndex((line) => /^\s*run:\s*\|\s*$/.test(line)) + if (runIndex === -1) return "" + const body = lines.slice(runIndex + 1) + const bodyIndent = Math.min(...body.filter((line) => line.trim() !== "").map((line) => indentation(line))) + return body.map((line) => (line.trim() === "" ? "" : line.slice(bodyIndent))).join("\n") +} + +const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'` + +const runBetaPushClassifier = ({ + changedPaths, + catalog, + before = {}, + after = {}, + headSubject = "", + changelogType = "blob", +}) => { + const classifier = extractBetaPushClassifier(workflows.beta) + assert.notEqual(classifier, "", "beta workflow classifier block") + + const directory = mkdtempSync(join(tmpdir(), "effectify-beta-classifier-")) + try { + const changedFile = join(directory, "changed-paths") + const catalogFile = join(directory, "release-manifests") + const uniqueChangedPaths = [...new Set(changedPaths)].sort() + const catalogEntries = Object.entries(catalog).sort(([left], [right]) => left.localeCompare(right)) + writeFileSync(changedFile, uniqueChangedPaths.length > 0 ? `${uniqueChangedPaths.join("\n")}\n` : "") + writeFileSync( + catalogFile, + catalogEntries.length > 0 ? `${catalogEntries.map(([path, name]) => `${name}\t${path}`).join("\n")}\n` : "", + ) + + const environment = { + ...process.env, + BASE: "base", + HEAD: "head", + HEAD_SUBJECT: headSubject, + CHANGELOG_TYPE: changelogType, + CHANGED: changedFile, + RELEASE_MANIFESTS: catalogFile, + } + const cases = [] + for (const [index, path] of Object.keys(catalog).entries()) { + for (const [revision, documents, prefix] of [ + ["base", before, "BEFORE"], + ["head", after, "AFTER"], + ]) { + const value = documents[path] + environment[`${prefix}_PRESENT_${index}`] = String(value !== undefined) + environment[`${prefix}_DOCUMENT_${index}`] = + value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value) + cases.push( + ` ${shellQuote(`${revision}:${path}`)}) [ "$${prefix}_PRESENT_${index}" = "true" ] || return 128; printf '%s' "$${prefix}_DOCUMENT_${index}" ;;`, + ) + } + } + + const result = spawnSync( + "bash", + [ + "-c", + `set -euo pipefail\ngit() {\n if [ "$1" = "cat-file" ] && [ "$2" = "-t" ] && [ "$3" = "head:CHANGELOG.md" ]; then\n [ "$CHANGELOG_TYPE" != "missing" ] || return 128\n printf '%s\\n' "$CHANGELOG_TYPE"\n return\n fi\n [ "$1" = "show" ] || return 127\n case "$2" in\n${cases.join("\n")}\n *) return 128 ;;\n esac\n}\n${classifier}\nclassify_push_shape`, + ], + { cwd: directory, encoding: "utf8", env: environment }, + ) + assert.equal(result.status, 0, result.stderr) + return result.stdout.trim() + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +const betaBeforeSha = "1111111111111111111111111111111111111111" +const betaHeadSha = "2222222222222222222222222222222222222222" +const betaManifestPath = "packages/future/nebula/package.json" +const betaProject = "@future/nebula" + +const runBetaPushResolver = ({ + beforeSha = betaBeforeSha, + headSha = betaHeadSha, + checkedOutHead = headSha, + beforeType = "commit", + headType = "commit", + changedPaths = ["packages/future/nebula/src/index.ts"], + beforeDocument = { name: betaProject, version: "4.7.0-beta.12" }, + afterDocument = beforeDocument, + changelogType = "blob", + affectedOutput = "[]", + affectedExit = 0, +} = {}) => { + const resolve = extractSteps(workflows.beta).find((step) => step.name.includes("Resolve beta mode and projects")) + const script = extractRunScript(resolve) + assert.notEqual(script, "", "beta resolver shell body") + + const directory = mkdtempSync(join(tmpdir(), "effectify-beta-resolver-")) + try { + const outputFile = join(directory, "github-output") + writeFileSync(join(directory, "nx.json"), JSON.stringify({ release: { projects: ["packages/future/nebula"] } })) + writeFileSync(outputFile, "") + const environment = { + ...process.env, + EVENT_NAME: "push", + PUBLISH_ONLY: "false", + REQUESTED_PROJECTS: "", + EXPECTED_SHA: "", + BEFORE_SHA: beforeSha, + HEAD_SHA: headSha, + HEAD_MESSAGE: "ordinary source push", + CHECKED_OUT_HEAD: checkedOutHead, + BEFORE_TYPE: beforeType, + HEAD_TYPE: headType, + CHANGED_PATHS: changedPaths.join("\n"), + MANIFEST_PATH: betaManifestPath, + BEFORE_DOCUMENT: typeof beforeDocument === "string" ? beforeDocument : JSON.stringify(beforeDocument), + AFTER_DOCUMENT: typeof afterDocument === "string" ? afterDocument : JSON.stringify(afterDocument), + CHANGELOG_TYPE: changelogType, + NX_AFFECTED_OUTPUT: affectedOutput, + NX_AFFECTED_EXIT: String(affectedExit), + GITHUB_OUTPUT: outputFile, + TMPDIR: directory, + } + const stubs = `pnpm() { + [ "$1" = nx ] || return 127 + if [ "$2" = show ] && [ "$3" = project ] && [ "$4" = packages/future/nebula ]; then + printf '%s\\n' '{"name":"@future/nebula","root":"packages/future/nebula"}' + return + fi + if [ "$2" = show ] && [ "$3" = projects ]; then + [ "$NX_AFFECTED_EXIT" = 0 ] || return "$NX_AFFECTED_EXIT" + printf '%s' "$NX_AFFECTED_OUTPUT" + return + fi + return 127 +} +git() { + if [ "$1" = cat-file ] && [ "$2" = -t ]; then + case "$3" in + "$BEFORE_SHA") [ "$BEFORE_TYPE" != missing ] || return 128; printf '%s\\n' "$BEFORE_TYPE" ;; + "$HEAD_SHA") [ "$HEAD_TYPE" != missing ] || return 128; printf '%s\\n' "$HEAD_TYPE" ;; + "$HEAD_SHA:CHANGELOG.md") [ "$CHANGELOG_TYPE" != missing ] || return 128; printf '%s\\n' "$CHANGELOG_TYPE" ;; + *) return 128 ;; + esac + return + fi + if [ "$1" = rev-parse ] && [ "$2" = HEAD ]; then printf '%s\\n' "$CHECKED_OUT_HEAD"; return; fi + if [ "$1" = diff ]; then [ -z "$CHANGED_PATHS" ] || printf '%s\\n' "$CHANGED_PATHS"; return; fi + if [ "$1" = show ]; then + case "$2" in + "$BEFORE_SHA:$MANIFEST_PATH") printf '%s' "$BEFORE_DOCUMENT" ;; + "$HEAD_SHA:$MANIFEST_PATH") printf '%s' "$AFTER_DOCUMENT" ;; + *) return 128 ;; + esac + return + fi + return 127 +}` + const result = spawnSync("bash", ["-c", `${stubs}\n${script}`], { + cwd: directory, + encoding: "utf8", + env: environment, + }) + const outputText = readFileSync(outputFile, "utf8") + const output = Object.fromEntries( + outputText + .split("\n") + .filter(Boolean) + .map((line) => { + const separator = line.indexOf("=") + return [line.slice(0, separator), line.slice(separator + 1)] + }), + ) + return { ...result, output, outputText } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + const channelViolations = (channel, source) => { const violations = [] const active = withoutComments(source) @@ -277,7 +650,7 @@ const channelViolations = (channel, source) => { } const betaViolations = (source) => { - const violations = [] + const violations = [...classifierStructureViolations(source)] const active = withoutComments(source) const steps = extractSteps(source) const resolve = steps.find((step) => step.commands.some((command) => /mode=prepare/.test(command))) @@ -296,17 +669,54 @@ const betaViolations = (source) => { violations.push("beta mode resolver") } else { const commands = resolve.commands.join("\n") + if (!resolve.commands.includes(betaFinalizeExpectedShaGuard)) { + violations.push("beta FINALIZE full expected SHA") + } if (!resolve.commands.includes("HEAD_SUBJECT=${HEAD_MESSAGE%%$'\\n'*}")) { violations.push("beta first-line release subject") } - if (!resolve.commands.includes(`if ${releaseSubjectGuard}; then`)) { - violations.push("beta subject-only message defense") + if (!resolve.commands.includes(releaseSubjectGuard)) { + violations.push("beta release-subject fail-closed defense") + } + if (!resolve.commands.includes(releaseManifestGuard)) { + violations.push("beta non-benign manifest fail-closed defense") + } + if (!resolve.commands.includes(exactBetaSuppressionGuard)) { + violations.push("beta exact suppression guard") + } + if (!resolve.commands.includes(classificationFailClosedGuard)) { + violations.push("beta classifier result fail-closed guard") + } + if (extractBetaPushClassifier(source) === "") { + violations.push("beta executable push classifier") + } + for (const command of [ + '[[ "$BEFORE_SHA" =~ ^[0-9a-f]{40}$ ]] && [ "$BEFORE_SHA" != "$ZERO_SHA" ] || {', + '[[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] && [ "$HEAD_SHA" != "$ZERO_SHA" ] || {', + 'BEFORE="$BEFORE_SHA"', + 'HEAD="$HEAD_SHA"', + 'test "$(git cat-file -t "$BEFORE" 2>/dev/null)" = "commit" || {', + 'test "$(git cat-file -t "$HEAD" 2>/dev/null)" = "commit" || {', + 'test "$(git rev-parse HEAD)" = "$HEAD" || {', + 'BASE="$BEFORE"', + oldManifestCardinalityGuard, + newManifestCardinalityGuard, + 'AFFECTED_RAW=$(pnpm nx show projects --affected --base="$BASE" --head="$HEAD" --json)', + 'printf \'%s\' "$AFFECTED_RAW" | jq -e -s \'length == 1 and (.[0] | type == "array" and all(.[]; type == "string"))\' >/dev/null', + 'AFFECTED_RELEASE_PROJECTS=$(printf \'%s\' "$AFFECTED_RAW" | jq -r --argjson release "$RELEASE_PROJECTS" \'[.[] | select(. as $project | $release | index($project))] | unique | join(",")\')', + ]) { + if (!resolve.commands.includes(command)) violations.push(`beta push resolver command ${command}`) + } + if (/AFFECTED_(?:RAW|RELEASE_PROJECTS)=.*\|\|\s*echo/.test(commands)) { + violations.push("beta affected project fail-open fallback") + } + if (/BASE="HEAD\^"|HEAD=\$\(git rev-parse HEAD\)/.test(commands)) { + violations.push("beta event range fallback") } for (const [pattern, name] of [ [/mode=prepare/, "prepare mode"], [/mode=finalize/, "finalize mode"], [/mode=suppress/, "suppress mode"], - [/\^\[0-9a-f\]\{40\}\$/, "full expected SHA"], [/git diff --name-only --no-renames/, "structural changed paths"], [/CHANGELOG\.md/, "root changelog shape"], [/-beta\\\.\[0-9\]/, "beta manifest transition"], @@ -314,20 +724,15 @@ const betaViolations = (source) => { [/\[skip release\]/, "skip message defense"], [/grep -Fx -- "\$project"/, "exact allowlist membership"], [/sort \| uniq -d/, "duplicate selection rejection"], - [/manual PREPARE requires all seven release projects/, "incident project set"], ]) { - if (!pattern.test(commands)) violations.push(`beta ${name}`) + if (name !== "beta manifest transition" && !pattern.test(commands)) violations.push(`beta ${name}`) } - for (const [project, version] of [ - ["@effectify/react-router", "0.6.0-beta.0"], - ["@effectify/react-query", "1.0.0-beta.1"], - ["@effectify/node-better-auth", "0.5.12-beta.0"], - ["@effectify/solid-query", "0.5.12-beta.0"], - ["@effectify/react-router-better-auth", "0.5.12-beta.0"], - ["@effectify/prisma", "1.1.13-beta.0"], - ["@effectify/hatchet", "0.1.0-beta.0"], - ]) { - if (!active.includes(`${project}=${version}`)) violations.push(`beta incident ${project}`) + if ( + /CORRECTIVE_|corrective solid-query|corrective beta|EXPECTED_MATRIX|version_specifier=prepatch|manual PREPARE requires all seven/.test( + active, + ) + ) { + violations.push("beta completed corrective policy") } } @@ -355,7 +760,6 @@ const betaViolations = (source) => { /^echo "::error::expected=\$\(paste -sd, \/tmp\/expected-release-paths\); actual=\$\(paste -sd, \/tmp\/staged-release-paths\)"$/, "safe staged-path annotation", ], - [/^'@effectify\/solid-query=0\.5\.12-beta\.0' \| sort > "\$EXPECTED_MATRIX"$/, "sorted incident matrix"], [exactCommand(`if ! ${terminalGates[0].command}; then`), "release commit"], ]) { if (!prepare.commands.some((command) => pattern.test(command))) violations.push(`beta PREPARE ${name}`) @@ -463,31 +867,35 @@ const betaViolations = (source) => { } } - for (const transition of [ - "@effectify/hatchet=0.1.0-beta.0=0.1.0|packages/hatchet/package.json", - "@effectify/node-better-auth=0.5.12-beta.0=0.5.12|packages/node/better-auth/package.json", - "@effectify/prisma=1.1.13-beta.0=1.1.13|packages/prisma/package.json", - "@effectify/react-query=1.0.0-beta.1=1.0.0|packages/react/query/package.json", - "@effectify/react-router=0.6.0-beta.0=0.6.0|packages/react/router/package.json", - "@effectify/react-router-better-auth=0.5.12-beta.0=0.5.12|packages/react/router-better-auth/package.json", - "@effectify/solid-query=0.5.13-beta.0=0.5.13|packages/solid/query/package.json", - ]) - if (!active.includes(transition)) violations.push(`beta stable transition ${transition}`) - if (/\bread\s+-r\s+TRANSITION\s+PATH\b/.test(active)) violations.push("beta stable reserved PATH shadowing") - for (const pattern of [ - /cmp -s "\$EXPECTED_PATHS" "\$CHANGED"/, - /git show "\$BASE:\$MANIFEST_PATH" \| jq -er \.name/, - /git show "\$BASE:\$MANIFEST_PATH" \| jq -er \.version/, - /jq -er \.name "\$MANIFEST_PATH"/, - /jq -er \.version "\$MANIFEST_PATH"/, - /\[ "\$OLD_NAME" = "\$NAME" \] && \[ "\$NEW_NAME" = "\$NAME" \] && \[ "\$OLD_VERSION" = "\$OLD" \] && \[ "\$NEW_VERSION" = "\$NEW" \]/, + if (/STABLE_TRANSITIONS|@effectify\/hatchet=0\.1\.0-beta|@effectify\/solid-query=0\.5\.13-beta/.test(active)) { + violations.push("beta historical stable matrix") + } + if (/\bread\s+-r\s+[^\n;]*\bPATH\b/.test(resolve?.source ?? "")) + violations.push("beta stable reserved PATH shadowing") + for (const [pattern, name] of [ + [/jq -r ['"]?\.release\.projects\[\]['"]? nx\.json/, "release roots from nx"], + [/pnpm nx show project "\$RELEASE_ROOT" --json/, "release project metadata"], + [/git show "\$BASE:\$MANIFEST_PATH"/, "old reviewed manifest"], + [/git show "\$HEAD:\$MANIFEST_PATH"/, "new reviewed manifest"], + [/HAS_CHANGELOG=true/, "required root changelog"], + [/git cat-file -t "\$HEAD:CHANGELOG\.md"/, "root changelog artifact blob"], + [/\[ "\$CHANGELOG_TYPE" != "blob" \]/, "non-blob changelog rejection"], + [/UNEXPECTED=true/, "unexpected path rejection"], + [/INVALID_MANIFESTS=0/, "invalid manifest rejection"], + [/BENIGN_MANIFEST_CHANGES=0/, "benign manifest tracking"], + [/\[ "\$OLD_NAME" != "\$NAME" \] \|\| \[ "\$NEW_NAME" != "\$NAME" \]/, "allowlisted manifest names"], + [/\[ "\$OLD_VERSION" = "\$NEW_VERSION" \]/, "unchanged benign manifest version"], + [ + /STABLE_VERSION="\$\{BASH_REMATCH\[1\]\}\.\$\{BASH_REMATCH\[2\]\}\.\$\{BASH_REMATCH\[3\]\}"/, + "derived stable target", + ], + [/\[ "\$NEW_VERSION" = "\$STABLE_VERSION" \]/, "exact beta-to-stable target"], ]) - if (!pattern.test(active)) violations.push(`beta stable structural check ${String(pattern)}`) - if ( - !/if \[ "\$HAS_CHANGELOG" = "true" \] && \[ "\$UNEXPECTED" = "false" \] && \[ "\$BETA_TRANSITIONS" -gt 0 \] && \[ "\$BETA_TRANSITIONS" -eq "\$MANIFEST_CHANGES" \]; then/.test( - active, - ) - ) { + if (!pattern.test(active)) violations.push(`beta stable structural check ${name}`) + if (!active.includes(stableTransitionVersionPattern)) { + violations.push("beta stable structural check no-leading-zero beta source") + } + if (!resolve?.commands.includes(exactBetaSuppressionGuard)) { violations.push("beta exact suppression shape") } if (!/echo "suspicious release-shaped master push; refusing preparation" >&2\s*\n\s*exit 1/.test(active)) { @@ -512,62 +920,204 @@ const betaViolations = (source) => { } const stableViolations = (source, finalizeScript = stableFinalizeScript) => { - const violations = [] + const violations = [...stableCapabilityViolations(source)] const active = withoutComments(source) const activeFinalize = withoutComments(finalizeScript) { const steps = extractSteps(source) const resolve = steps.find((step) => step.name.includes("Resolve exact stable mode")) - const freshAuthorization = steps.find((step) => step.name.includes("Fresh master authorization")) + const freshAuthorization = resolve const prepare = steps.find((step) => step.name.includes("PREPARE protected stable")) const preflight = steps.find((step) => step.name.includes("PREFLIGHT exact stable artifacts")) const finalize = steps.find((step) => step.name.includes("FINALIZE exact stable artifacts")) const finalizeBody = finalize?.source ?? "" const required = [ ["wrapper exec", /exec node .*release-finalize-stable\.mjs/, withoutComments(stableFinalizeWrapper)], - ["preflight boolean input", /preflight_only:\s*\n\s*description: ["']Read-only exact-state verification[^\n]*\n\s*required: true\s*\n\s*type: boolean\s*\n\s*default: false/, active], + [ + "preflight boolean input", + /preflight_only:\s*\n\s*description: ["']Read-only exact-state verification[^\n]*\n\s*required: true\s*\n\s*type: boolean\s*\n\s*default: false/, + active, + ], ["expected SHA input", /expected_sha:\s*\n\s*description:[^\n]*\n\s*required: false\s*\n\s*type: string/, active], ["artifact SHA input", /artifact_sha:\s*\n\s*description:[^\n]*\n\s*required: false\s*\n\s*type: string/, active], - ["expected SHA finalizer env", /EXPECTED_SHA:\s*\$\{\{ inputs\.expected_sha \}\}/, finalizeBody], - ["artifact SHA finalizer env", /ARTIFACT_SHA:\s*\$\{\{ inputs\.artifact_sha \}\}/, finalizeBody], + [ + "expected SHA finalizer env", + /EXPECTED_SHA:\s*\$\{\{ needs\.validate\.outputs\.expected_sha \}\}/, + finalizeBody, + ], + [ + "artifact SHA finalizer env", + /ARTIFACT_SHA:\s*\$\{\{ needs\.validate\.outputs\.artifact_sha \}\}/, + finalizeBody, + ], + ["FINALIZE normalized selection", /PROJECTS:\s*\$\{\{ needs\.validate\.outputs\.projects \}\}/, finalizeBody], ["expected SHA environment", /const expectedSha = process\.env\.EXPECTED_SHA \?\? ""/, activeFinalize], ["artifact SHA fallback", /const artifactSha = process\.env\.ARTIFACT_SHA \|\| expectedSha/, activeFinalize], ["historical SHA distinction", /const historicalReplay = artifactSha !== expectedSha/, activeFinalize], - ["strict expected SHA", /if \(!\/\^\[0-9a-f\]\{40\}\$\/\.test\(expectedSha\)\) fail\("FINALIZE requires full lowercase expected SHA"\)/, activeFinalize], - ["strict artifact SHA", /if \(!\/\^\[0-9a-f\]\{40\}\$\/\.test\(artifactSha\)\) fail\("FINALIZE requires full lowercase artifact SHA"\)/, activeFinalize], + [ + "import-safe URL-aware main-module guard", + /function isMainModule\(\) \{[\s\S]*const entry = process\.argv\[1\][\s\S]*if \(!entry\) return false[\s\S]*resolvedEntry = realpathSync\(entry\)[\s\S]*resolvedModule = realpathSync\(fileURLToPath\(import\.meta\.url\)\)[\s\S]*catch \{[\s\S]*return false[\s\S]*pathToFileURL\(resolvedEntry\)\.href === pathToFileURL\(resolvedModule\)\.href/, + activeFinalize, + ], + [ + "strict expected SHA", + /if \(!\/\^\[0-9a-f\]\{40\}\$\/\.test\(expectedSha\)\) fail\("FINALIZE requires full lowercase expected SHA"\)/, + activeFinalize, + ], + [ + "strict artifact SHA", + /if \(!\/\^\[0-9a-f\]\{40\}\$\/\.test\(artifactSha\)\) fail\("FINALIZE requires full lowercase artifact SHA"\)/, + activeFinalize, + ], ["fresh master", /master:refs\/remotes\/origin\/master/, activeFinalize], - ["HEAD execution authorization", /if \(head !== expectedSha\) fail\("HEAD does not match expected SHA"\)/, activeFinalize], - ["origin execution authorization", /if \(origin !== expectedSha\) fail\("origin\/master does not match expected SHA"\)/, activeFinalize], - ["manifest identity", /value\.name !== name \|\| value\.version !== version/, activeFinalize], + [ + "HEAD execution authorization", + /if \(head !== expectedSha\) fail\("HEAD does not match expected SHA"\)/, + activeFinalize, + ], + [ + "origin execution authorization", + /if \(origin !== expectedSha\) fail\("origin\/master does not match expected SHA"\)/, + activeFinalize, + ], + [ + "requested projects environment", + /const requestedProjectsText = process\.env\.PROJECTS \?\? ""/, + activeFinalize, + ], + [ + "artifact changelog blob before publication inspection", + /await verifyArtifactChangelog\(\)[\s\S]*const records = await deriveReviewedRecords\(projects\)[\s\S]*const states = await inspect\(records\)/, + activeFinalize, + ], + [ + "bounded artifact changelog type inspection", + /run\("git", \["cat-file", "-t", `\$\{artifactSha\}:CHANGELOG\.md`\]\)/, + activeFinalize, + ], + ["artifact nx release roots", /artifactJson\("nx\.json"/, activeFinalize], + ["artifact project identity", /artifactJson\(`\$\{root\}\/project\.json`/, activeFinalize], + ["artifact manifest identity", /artifactJson\(manifestPath/, activeFinalize], + [ + "single-parent or exact two-parent artifact", + /if \(parents\.length === 1\) return[\s\S]*if \(parents\.length !== 2\) fail\("reviewed artifact must be a single-parent commit or exact two-parent merge"\)/, + activeFinalize, + ], + [ + "merge second parent based directly on first parent", + /generatedParents\.length !== 1 \|\| generatedParents\[0\] !== firstParent/, + activeFinalize, + ], + [ + "merge tree equals generated second parent", + /\["rev-parse", `\$\{generatedParent\}\^\{tree\}`, `\$\{artifactSha\}\^\{tree\}`\][\s\S]*treeIds\[0\] !== treeIds\[1\]/, + activeFinalize, + ], + [ + "first-parent reviewed diff", + /\["diff", "--name-only", "--no-renames", `\$\{artifactSha\}\^1`, artifactSha\]/, + activeFinalize, + ], + ["strict beta source", /previous\.version\.match\(\/\^.*-beta\\\./, activeFinalize], + [ + "derived stable target", + /const stableVersion = match \? `\$\{match\[1\]\}\.\$\{match\[2\]\}\.\$\{match\[3\]\}`/, + activeFinalize, + ], + [ + "reviewed selection equality", + /requested projects do not exactly match reviewed manifest changes/, + activeFinalize, + ], + ["GitHub Actions FINALIZE boundary", /process\.env\.GITHUB_ACTIONS !== "true"/, activeFinalize], ["bounded npm reads", /const maxReads = 6\b/, activeFinalize], ["post-publish absence retries", /acceptAbsent && state\.kind === "absent"/, activeFinalize], - ["local annotated tag inspection", /async function localTagState[\s\S]*objecttype[\s\S]*\^tag\\t/, activeFinalize], - ["independent npm documents", /const versionsDoc[\s\S]*const latestDoc/, activeFinalize], - ["strict tag parse", /direct\.length === 1 && peeled\.length === 1 && peeled\[0\] === artifactSha/, activeFinalize], + [ + "local annotated tag inspection", + /async function localTagState[\s\S]*objecttype[\s\S]*\^tag\\t/, + activeFinalize, + ], + ["independent npm documents", /const versionsDoc[\s\S]*const tagsDoc/, activeFinalize], + [ + "strict tag parse", + /direct\.length === 1 && peeled\.length === 1 && peeled\[0\] === artifactSha/, + activeFinalize, + ], ["local artifact tag target", /match && match\[1\] === artifactSha/, activeFinalize], ["HTTP 404 absence", /result\.status === 404/, activeFinalize], ["unknown Release fail closed", /result\.status !== 200/, activeFinalize], ["annotated artifact tag", /\["tag", "-a", tag, artifactSha, "-m", tag\]/, activeFinalize], ["atomic explicit push", /\["push", "--atomic", "origin", \.\.\.refs\]/, activeFinalize], - ["release exact postverification", /releaseState\(`\$\{item\.name\}@\$\{item\.version\}`\)\)\.kind !== "exact"/, activeFinalize], - ["missing npm subset", /states\.filter\(\(x\) => x\.npm === "absent"\)/, activeFinalize], - ["default publication", /\["nx", "release", "publish", `--projects=\$\{missing\.join\(","\)\}`\]/, activeFinalize], - ["historical all-existing guard", /if \(historicalReplay\) \{[\s\S]*item\.tag !== "exact" \|\| item\.release !== "exact" \|\| item\.npm !== "exact"[\s\S]*historical replay requires exact existing tag, GitHub Release, and npm latest/, activeFinalize], - ["preflight both SHAs", /JSON\.stringify\(\{ ok: true, expectedSha, artifactSha, states \}\)/, activeFinalize], - ["preflight return", /if \(preflight\) \{[\s\S]*return \}/, activeFinalize], - ["PREPARE Node JSON type validation", /JSON\.parse\(/, active], - ["PREPARE manifest object type", /!value\|\|typeof value!=="object"\|\|Array\.isArray\(value\)/, active], - ["PREPARE manifest name type", /typeof value\.name!=="string"/, active], - ["PREPARE manifest version type", /typeof value\.version!=="string"/, active], - ["PREPARE exact SHA", /\[\[ "\$EXPECTED_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\]/, active], + [ + "release exact postverification", + /releaseState\(`\$\{item\.name\}@\$\{item\.version\}`\)\)\.kind !== "exact"/, + activeFinalize, + ], + [ + "missing npm subset", + /states\.filter\(\((?:state|item)\) => (?:state|item)\.npm === "absent"\)/, + activeFinalize, + ], + [ + "default publication", + /\["nx", "release", "publish", `--projects=\$\{missing\.join\(","\)\}`\]/, + activeFinalize, + ], + [ + "publish lifecycle-script environment", + /env:\s*\{\s*\.\.\.process\.env,\s*NPM_CONFIG_IGNORE_SCRIPTS:\s*"true"\s*\}/, + activeFinalize, + ], + [ + "historical all-existing guard", + /if \(historicalReplay\) \{[\s\S]*item\.tag !== "exact" \|\| item\.release !== "exact" \|\| item\.npm !== "exact"[\s\S]*historical replay requires exact existing tag, GitHub Release, and npm latest/, + activeFinalize, + ], + [ + "preflight reviewed selection", + /JSON\.stringify\(\{ ok: true, expectedSha, artifactSha, projects, states \}\)/, + activeFinalize, + ], + ["preflight return", /if \(historicalReplay \|\| preflight\) return/, activeFinalize], + ["selection release roots from nx", /jq -r ['"]?\.release\.projects\[\]['"]? nx\.json/, active], + ["selection project metadata", /pnpm nx show project "\$RELEASE_ROOT" --json/, active], + ["selection exact allowlist", /grep -Fx -- "\$project"/, active], + ["selection duplicate rejection", /sort \| uniq -d/, active], + ["PREPARE derived manifest", /MANIFEST_PATH="\$ROOT\/package\.json"/, active], + ["PREPARE strict beta source", /-beta\\\.\(0\|\[1-9\]\[0-9\]\*\)\$/, active], + [ + "PREPARE derived stable target", + /NEW="\$\{BASH_REMATCH\[1\]\}\.\$\{BASH_REMATCH\[2\]\}\.\$\{BASH_REMATCH\[3\]\}"/, + active, + ], ["PREPARE Nx flags", /--git-commit=false --git-tag=false --git-push=false --stage-changes=false/, active], ["PREPARE expected path equality", /cmp -s "\$EXPECTED_PATHS" "\$ACTUAL"/, active], ["PREPARE exact staging", /git add --pathspec-from-file="\$EXPECTED_PATHS"/, active], - ["PREPARE staged path equality", /cmp -s "\$EXPECTED_PATHS" \/tmp\/stable-staged/, active], - ["PREPARE release branch", /HEAD:refs\/heads\/release\/stable-\$SHA_PREFIX/, active], - ["read-only PREFLIGHT summary", /PREFLIGHT is read-only exact-state verification; it does not tag, push, create Releases, or publish\./, active], + ["PREPARE staged path equality", /cmp -s "\$EXPECTED_PATHS" "\$STAGED_PATHS"/, active], + [ + "PREPARE committed changelog blob", + /git cat-file -t "\$RELEASE_SHA:CHANGELOG\.md"[\s\S]*\[ "\$RELEASE_CHANGELOG_TYPE" != "blob" \]/, + active, + ], + [ + "validate reviewed artifact changelog blob", + /git cat-file -t "\$RESOLVED_ARTIFACT_SHA:CHANGELOG\.md"[\s\S]*\[ "\$ARTIFACT_CHANGELOG_TYPE" != "blob" \]/, + active, + ], + ["PREPARE release branch", /BRANCH="release\/stable-\$SHA_PREFIX"/, active], + [ + "read-only PREFLIGHT summary", + /PREFLIGHT is read-only exact-state verification; it does not tag, push, create Releases, or publish\./, + active, + ], ] for (const [name, pattern, body] of required) if (!pattern.test(body)) violations.push(`stable ${name}`) + if (!active.includes(stableTransitionVersionPattern)) { + violations.push("stable PREPARE no-leading-zero beta source") + } + if (!activeFinalize.includes(stableTransitionVersionPattern)) { + violations.push("stable FINALIZE no-leading-zero beta source") + } if (!resolve) { violations.push("stable mode resolver") @@ -588,44 +1138,72 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { if (!preflightBranch) { violations.push("stable PREFLIGHT mode branch") } else { - if (!/\[\[ "\$EXPECTED_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\] \|\| \{ echo '::error::PREFLIGHT requires full lowercase expected_sha'; exit 1; \}/.test(preflightBranch)) { + if ( + !/\[\[ "\$EXPECTED_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\] \|\| \{ echo '::error::PREFLIGHT requires full lowercase expected_sha'; exit 1; \}/.test( + preflightBranch, + ) + ) { violations.push("stable PREFLIGHT full expected SHA") } - if (!/if \[ -n "\$ARTIFACT_SHA" \]; then \[\[ "\$ARTIFACT_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\] \|\| \{ echo '::error::PREFLIGHT requires full lowercase artifact_sha'; exit 1; \}; fi/.test(preflightBranch)) { + if ( + !/if \[ -n "\$ARTIFACT_SHA" \]; then \[\[ "\$ARTIFACT_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\] \|\| \{ echo '::error::PREFLIGHT requires full lowercase artifact_sha'; exit 1; \}; fi/.test( + preflightBranch, + ) + ) { violations.push("stable PREFLIGHT optional full artifact SHA") } if (!/MODE=preflight/.test(preflightBranch)) violations.push("stable PREFLIGHT mode output") } + if (!resolve.commands.includes(stableFinalizeExpectedShaGuard)) { + violations.push("stable FINALIZE full expected SHA guard") + } } const freshSequence = [ /^if \[ "\$MODE" = preflight \] \|\| \[ "\$MODE" = finalize \]; then$/, /^test "\$HEAD_SHA" = "\$EXPECTED_SHA" \|\| \{ echo '::error::PREFLIGHT\/FINALIZE SHA mismatch'; exit 1; \}$/, + /^if ! ARTIFACT_CHANGELOG_TYPE=\$\(git cat-file -t "\$RESOLVED_ARTIFACT_SHA:CHANGELOG\.md" 2>\/dev\/null\) \|\| \[ "\$ARTIFACT_CHANGELOG_TYPE" != "blob" \]; then$/, + /^echo '::error::reviewed stable artifact requires root CHANGELOG\.md to exist as a blob'$/, + /^exit 1$/, + /^fi$/, /^fi$/, ] if (!freshAuthorization || !hasCommandSequence(freshAuthorization.commands, freshSequence)) { violations.push("stable fresh PREFLIGHT and FINALIZE expected SHA authorization") } - if (!prepare || prepare.condition !== "${{ steps.release.outputs.mode == 'prepare' }}") { + if ( + !prepare || + !/^\s{4}if:\s*\$\{\{ needs\.validate\.outputs\.mode == 'prepare' \}\}\s*$/m.test(extractJob(source, "prepare")) + ) { violations.push("stable PREPARE-only step") } - if (!finalize || finalize.condition !== "${{ steps.release.outputs.mode == 'finalize' }}") { + if ( + !finalize || + !/^\s{4}if:\s*\$\{\{ needs\.validate\.outputs\.mode == 'finalize' \}\}\s*$/m.test(extractJob(source, "finalize")) + ) { violations.push("stable FINALIZE-only step") } if (!preflight) { violations.push("stable PREFLIGHT step") } else { - if (preflight.condition !== "${{ steps.release.outputs.mode == 'preflight' }}") { + if ( + !/^\s{4}if:\s*\$\{\{ needs\.validate\.outputs\.mode == 'preflight' \}\}\s*$/m.test( + extractJob(source, "preflight"), + ) + ) { violations.push("stable PREFLIGHT-only step") } - if (!/EXPECTED_SHA:\s*\$\{\{ inputs\.expected_sha \}\}/.test(preflight.source)) { + if (!/EXPECTED_SHA:\s*\$\{\{ needs\.validate\.outputs\.expected_sha \}\}/.test(preflight.source)) { violations.push("stable PREFLIGHT expected SHA environment") } - if (!/ARTIFACT_SHA:\s*\$\{\{ inputs\.artifact_sha \}\}/.test(preflight.source)) { + if (!/ARTIFACT_SHA:\s*\$\{\{ needs\.validate\.outputs\.artifact_sha \}\}/.test(preflight.source)) { violations.push("stable PREFLIGHT artifact SHA environment") } - if (!/GITHUB_TOKEN:\s*\$\{\{ secrets\.GITHUB_TOKEN \}\}/.test(preflight.source)) { + if (!/PROJECTS:\s*\$\{\{ needs\.validate\.outputs\.projects \}\}/.test(preflight.source)) { + violations.push("stable PREFLIGHT normalized selection") + } + if (!/GITHUB_TOKEN:\s*\$\{\{ github\.token \}\}/.test(preflight.source)) { violations.push("stable PREFLIGHT GitHub token") } if ( @@ -645,12 +1223,25 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { const prepareBody = prepare?.source ?? "" if (/\bread\s+-r\s+[^\n;]*\bPATH\b/.test(prepareBody)) violations.push("stable PREPARE reserved PATH shadowing") - if (!/node -e '[^\n]*fs\.readFileSync\(path,"utf8"\)[^\n]*' "\$MANIFEST_PATH" "\$NAME"/.test(prepareBody)) { - violations.push("stable PREPARE MANIFEST_PATH manifest command") + if (/printf '%s\\n' '@effectify\/|0\.1\.0-beta\.0|0\.5\.13-beta\.0/.test(prepareBody)) { + violations.push("stable PREPARE historical records") } - if (/npm dist-tag|npm unpublish|gh release delete|git tag -f|--tag=(?:alpha|beta)/.test(activeFinalize)) violations.push("stable destructive or channel repair") - const order = ["const states = await inspect()", '["tag", "-a"', '["push", "--atomic"', 'github("POST"', 'releaseState(`${item.name}', '["nx", "release", "publish"', "npmBounded(item.name"].map((token) => activeFinalize.indexOf(token)) - if (order.some((position) => position < 0) || order.some((position, index) => index > 0 && position <= order[index - 1])) violations.push("stable ordering") + if (/npm dist-tag|npm unpublish|gh release delete|git tag -f|--tag=(?:alpha|beta)/.test(activeFinalize)) + violations.push("stable destructive or channel repair") + const order = [ + "const states = await inspect(records)", + '["tag", "-a"', + '["push", "--atomic"', + 'github("POST"', + "releaseState(`${item.name}", + '["nx", "release", "publish"', + "const state = await npmBounded(item.name", + ].map((token) => activeFinalize.indexOf(token)) + if ( + order.some((position) => position < 0) || + order.some((position, index) => index > 0 && position <= order[index - 1]) + ) + violations.push("stable ordering") return violations } } @@ -665,8 +1256,75 @@ const releasePolicyBootstrapViolations = (source) => { return pnpmIndex !== -1 && pnpmIndex < setupNodeIndex ? [] : cacheDisabled ? [] : ["release-policy setup-node cache"] } +const stableReleasePrGuardViolations = (source) => { + const violations = [] + const job = extractJob(source, "release-policy") + const steps = extractSteps(job) + const condition = + "github.event_name == 'pull_request' && startsWith(github.event.pull_request.head.ref, 'release/stable-')" + const checkout = steps.find((step) => step.name.includes("Checkout stable release PR head")) + const guard = steps.find((step) => step.name.includes("Require one stable release source commit")) + const ordinaryCheckout = steps.find((step) => step.name === "📥 Checkout") + + if (!hasExactPermissions(job, { contents: "read" })) { + violations.push("stable release PR read-only permissions") + } + if (!checkout || checkout.condition !== condition || !checkout.uses.startsWith("actions/checkout@")) { + violations.push("stable release PR head checkout") + } else { + for (const [pattern, name] of [ + [/ref:\s*\$\{\{ github\.event\.pull_request\.head\.sha \}\}/, "exact head SHA checkout"], + [/fetch-depth:\s*0/, "full head history"], + [/persist-credentials:\s*false/, "non-persisted checkout credentials"], + ]) + if (!pattern.test(checkout.source)) violations.push(`stable release PR ${name}`) + } + + if (!guard || guard.condition !== condition) { + violations.push("stable release PR conditional guard") + return violations + } + if (!ordinaryCheckout || ordinaryCheckout.condition || !ordinaryCheckout.uses.startsWith("actions/checkout@")) { + violations.push("ordinary CI checkout remains unconditional") + } else if ( + steps.indexOf(checkout) >= steps.indexOf(guard) || + steps.indexOf(guard) >= steps.indexOf(ordinaryCheckout) + ) { + violations.push("stable release PR guard ordering") + } + for (const [pattern, name] of [ + [/PR_HEAD_REF:\s*\$\{\{ github\.event\.pull_request\.head\.ref \}\}/, "head ref environment"], + [/PR_HEAD_SHA:\s*\$\{\{ github\.event\.pull_request\.head\.sha \}\}/, "head SHA environment"], + [/PR_BASE_SHA:\s*\$\{\{ github\.event\.pull_request\.base\.sha \}\}/, "base SHA environment"], + ]) + if (!pattern.test(guard.source)) violations.push(`stable release PR ${name}`) + + if (guard.commands.some((command) => command.includes("${{"))) { + violations.push("stable release PR shell expression interpolation") + } + const commands = guard.commands.join("\n") + for (const [pattern, name] of [ + [/^\[\[ "\$PR_HEAD_REF" == release\/stable-\* \]\] \|\| /m, "head branch prefix validation"], + [/^\[\[ "\$PR_HEAD_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\] \|\| /m, "full head SHA validation"], + [/^\[\[ "\$PR_BASE_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\] \|\| /m, "full base SHA validation"], + [/^git fetch --no-tags --no-write-fetch-head origin "\$PR_BASE_SHA"$/m, "exact base fetch"], + [/^test "\$\(git rev-parse HEAD\)" = "\$PR_HEAD_SHA" \|\| /m, "checked-out head equality"], + [/^git cat-file -e "\$\{PR_HEAD_SHA\}\^\{commit\}"$/m, "head commit existence"], + [/^git cat-file -e "\$\{PR_BASE_SHA\}\^\{commit\}"$/m, "base commit existence"], + [/^SOURCE_COMMIT_COUNT=\$\(git rev-list --count "\$PR_BASE_SHA\.\.\$PR_HEAD_SHA"\)$/m, "base-to-head count"], + [/^if \[ "\$SOURCE_COMMIT_COUNT" != 1 \]; then$/m, "exactly one source commit"], + ]) + if (!pattern.test(commands)) violations.push(`stable release PR ${name}`) + + return violations +} + const policyViolations = ({ alpha, beta, stable, stableFinalize = stableFinalizeScript, docs }) => { - const violations = [...channelViolations("alpha", alpha), ...betaViolations(beta), ...stableViolations(stable, stableFinalize)] + const violations = [ + ...channelViolations("alpha", alpha), + ...betaViolations(beta), + ...stableViolations(stable, stableFinalize), + ] if (!/\|\s*Beta\s*\|[^\n]*`master`[^\n]*`beta`/.test(withoutComments(docs))) { violations.push("documented mapping") } @@ -690,16 +1348,309 @@ const mutateStep = (source, stepName, before, after) => { return source.replace(step.source, mutate(step.source, before, after)) } -test("dev pushes retain exact-range conditional alpha publication", () => { - assert.deepEqual(channelViolations("alpha", workflows.alpha), []) +test("release policy baseline has zero violations", () => { + assert.deepEqual(policyViolations({ ...workflows, docs: readme }), []) + assert.deepEqual(stableCapabilityViolations(workflows.stable), []) +}) + +test("the executable beta resolver classifier enforces the manifest truth table", () => { + const nebula = "packages/future/nebula/package.json" + const orbit = "packages/future/orbit/package.json" + const catalog = { + [nebula]: "@future/nebula", + [orbit]: "@future/orbit", + } + const nebulaBeta = { name: "@future/nebula", version: "4.7.0-beta.12" } + const nebulaStable = { name: "@future/nebula", version: "4.7.0" } + const orbitBeta = { name: "@future/orbit", version: "8.0.1-beta.3" } + const orbitStable = { name: "@future/orbit", version: "8.0.1" } + const benignBefore = { ...nebulaBeta, license: "MIT" } + const benignAfter = { ...nebulaBeta, license: "Apache-2.0" } + + const exactPromotion = { + changedPaths: ["CHANGELOG.md", nebula, orbit], + catalog, + before: { [nebula]: nebulaBeta, [orbit]: orbitBeta }, + after: { [nebula]: nebulaStable, [orbit]: orbitStable }, + } + for (const [name, candidate, expected] of [ + [ + "benign metadata with unchanged allowlisted name and version prepares", + { changedPaths: [nebula], catalog, before: { [nebula]: benignBefore }, after: { [nebula]: benignAfter } }, + "prepare", + ], + [ + "a source edit plus a benign manifest edit prepares", + { + changedPaths: ["packages/future/nebula/src/index.ts", nebula], + catalog, + before: { [nebula]: benignBefore }, + after: { [nebula]: benignAfter }, + }, + "prepare", + ], + [ + "formatting-only benign manifest JSON prepares", + { + changedPaths: [nebula], + catalog, + before: { [nebula]: '{"name":"@future/nebula","version":"4.7.0-beta.12"}' }, + after: { [nebula]: '{\n "name": "@future/nebula",\n "version": "4.7.0-beta.12"\n}' }, + }, + "prepare", + ], + ["exact changelog plus all beta-to-stable manifests suppresses", exactPromotion, "suppress"], + [ + "a deleted changelog rejects an otherwise exact promotion", + { ...exactPromotion, changelogType: "missing" }, + "reject", + ], + [ + "a tree at the changelog path rejects an otherwise exact promotion", + { ...exactPromotion, changelogType: "tree" }, + "reject", + ], + [ + "malformed old reviewed manifest rejects", + { changedPaths: [nebula], catalog, before: { [nebula]: '{"name":' }, after: { [nebula]: nebulaBeta } }, + "reject", + ], + [ + "malformed new reviewed manifest rejects", + { changedPaths: [nebula], catalog, before: { [nebula]: nebulaBeta }, after: { [nebula]: '{"name":' } }, + "reject", + ], + [ + "multiple old reviewed JSON documents reject", + { + changedPaths: [nebula], + catalog, + before: { [nebula]: `${JSON.stringify(nebulaBeta)}\n${JSON.stringify(nebulaBeta)}` }, + after: { [nebula]: nebulaBeta }, + }, + "reject", + ], + [ + "multiple new reviewed JSON documents reject", + { + changedPaths: [nebula], + catalog, + before: { [nebula]: nebulaBeta }, + after: { [nebula]: `${JSON.stringify(nebulaBeta)}\n${JSON.stringify(nebulaBeta)}` }, + }, + "reject", + ], + [ + "an old reviewed JSON array rejects", + { changedPaths: [nebula], catalog, before: { [nebula]: [nebulaBeta] }, after: { [nebula]: nebulaBeta } }, + "reject", + ], + [ + "a new reviewed JSON array rejects", + { changedPaths: [nebula], catalog, before: { [nebula]: nebulaBeta }, after: { [nebula]: [nebulaBeta] } }, + "reject", + ], + [ + "an old reviewed JSON scalar rejects", + { changedPaths: [nebula], catalog, before: { [nebula]: "false" }, after: { [nebula]: nebulaBeta } }, + "reject", + ], + [ + "a new reviewed JSON scalar rejects", + { changedPaths: [nebula], catalog, before: { [nebula]: nebulaBeta }, after: { [nebula]: "null" } }, + "reject", + ], + [ + "an empty old reviewed document rejects", + { changedPaths: [nebula], catalog, before: { [nebula]: "" }, after: { [nebula]: nebulaBeta } }, + "reject", + ], + [ + "an empty new reviewed document rejects", + { changedPaths: [nebula], catalog, before: { [nebula]: nebulaBeta }, after: { [nebula]: "" } }, + "reject", + ], + [ + "deleted reviewed manifest rejects", + { changedPaths: [nebula], catalog, before: { [nebula]: nebulaBeta }, after: {} }, + "reject", + ], + [ + "renamed manifest path rejects", + { + changedPaths: [nebula, "packages/future/renamed/package.json"], + catalog, + before: { [nebula]: nebulaBeta }, + after: {}, + }, + "reject", + ], + [ + "package rename rejects", + { + changedPaths: [nebula], + catalog, + before: { [nebula]: nebulaBeta }, + after: { [nebula]: { ...nebulaBeta, name: "@future/renamed" } }, + }, + "reject", + ], + [ + "arbitrary version bump rejects", + { + changedPaths: [nebula], + catalog, + before: { [nebula]: nebulaBeta }, + after: { [nebula]: { ...nebulaBeta, version: "4.7.0-beta.13" } }, + }, + "reject", + ], + [ + "manifest-only beta-to-stable transition rejects", + { changedPaths: [nebula], catalog, before: { [nebula]: nebulaBeta }, after: { [nebula]: nebulaStable } }, + "reject", + ], + [ + "partial promotion mixed with benign metadata rejects", + { + changedPaths: ["CHANGELOG.md", nebula, orbit], + catalog, + before: { [nebula]: nebulaBeta, [orbit]: { ...orbitBeta, license: "MIT" } }, + after: { [nebula]: nebulaStable, [orbit]: { ...orbitBeta, license: "Apache-2.0" } }, + }, + "reject", + ], + [ + "mixed promotion with an arbitrary version rejects", + { + changedPaths: ["CHANGELOG.md", nebula, orbit], + catalog, + before: { [nebula]: nebulaBeta, [orbit]: orbitBeta }, + after: { [nebula]: nebulaStable, [orbit]: { ...orbitBeta, version: "8.0.2" } }, + }, + "reject", + ], + [ + "changelog-bearing non-promotion rejects", + { + changedPaths: ["CHANGELOG.md", nebula], + catalog, + before: { [nebula]: benignBefore }, + after: { [nebula]: benignAfter }, + }, + "reject", + ], + [ + "extra changed path prevents exact promotion suppression", + { ...exactPromotion, changedPaths: [...exactPromotion.changedPaths, "README.md"] }, + "reject", + ], + [ + "release subject rejects even an otherwise exact promotion", + { ...exactPromotion, headSubject: "chore(release): promote stable" }, + "reject", + ], + ]) { + assert.equal(runBetaPushClassifier(candidate), expected, name) + } + + assert.doesNotMatch(withoutComments(workflows.beta), /CORRECTIVE_|corrective solid-query|corrective beta/) }) -test("beta incident matrix canonicalization matches sorted actual output", () => { - const unsortedExpected = ["@effectify/react-router=0.6.0-beta.0", "@effectify/hatchet=0.1.0-beta.0"] - const sortedActual = [...unsortedExpected].sort() +test("the actual beta push resolver fails closed on manifests, event SHAs, and Nx output", () => { + const benignBefore = { name: betaProject, version: "4.7.0-beta.12", license: "MIT" } + const benignAfter = { ...benignBefore, license: "Apache-2.0" } + for (const [name, candidate] of [ + [ + "source plus benign manifest", + { + changedPaths: ["packages/future/nebula/src/index.ts", betaManifestPath], + beforeDocument: benignBefore, + afterDocument: benignAfter, + affectedOutput: JSON.stringify([betaProject]), + }, + ], + [ + "formatting-only benign manifest", + { + changedPaths: [betaManifestPath], + beforeDocument: '{"name":"@future/nebula","version":"4.7.0-beta.12"}', + afterDocument: '{\n "name": "@future/nebula",\n "version": "4.7.0-beta.12"\n}', + affectedOutput: JSON.stringify([betaProject]), + }, + ], + ]) { + const result = runBetaPushResolver(candidate) + assert.equal(result.status, 0, `${name}: ${result.stderr}`) + assert.deepEqual(result.output, { mode: "prepare", has_projects: "true", projects: betaProject }) + } + + const validDocument = { name: betaProject, version: "4.7.0-beta.12" } + const serializedDocument = JSON.stringify(validDocument) + for (const [name, candidate] of [ + ["malformed old manifest", { beforeDocument: '{"name":' }], + ["malformed new manifest", { afterDocument: '{"name":' }], + ["multiple old manifest documents", { beforeDocument: `${serializedDocument}\n${serializedDocument}` }], + ["multiple new manifest documents", { afterDocument: `${serializedDocument}\n${serializedDocument}` }], + ["old manifest array", { beforeDocument: [validDocument] }], + ["new manifest array", { afterDocument: [validDocument] }], + ["old manifest scalar", { beforeDocument: "false" }], + ["new manifest scalar", { afterDocument: "null" }], + ["empty old manifest document", { beforeDocument: "" }], + ["empty new manifest document", { afterDocument: "" }], + ]) { + const result = runBetaPushResolver({ + changedPaths: [betaManifestPath], + beforeDocument: validDocument, + afterDocument: validDocument, + affectedOutput: JSON.stringify([betaProject]), + ...candidate, + }) + assert.notEqual(result.status, 0, name) + assert.deepEqual(result.output, {}, name) + } + + const deletedChangelog = runBetaPushResolver({ + changedPaths: ["CHANGELOG.md", betaManifestPath], + beforeDocument: { name: betaProject, version: "4.7.0-beta.12" }, + afterDocument: { name: betaProject, version: "4.7.0" }, + changelogType: "missing", + }) + assert.notEqual(deletedChangelog.status, 0) + assert.deepEqual(deletedChangelog.output, {}) + + for (const [name, candidate] of [ + ["missing before SHA", { beforeSha: "" }], + ["uppercase head SHA", { headSha: "A".repeat(40) }], + ["zero before SHA", { beforeSha: "0".repeat(40) }], + ["unresolvable before SHA", { beforeType: "missing" }], + ["non-commit head object", { headType: "tree" }], + ["checked-out HEAD mismatch", { checkedOutHead: "3".repeat(40) }], + ]) { + const result = runBetaPushResolver(candidate) + assert.notEqual(result.status, 0, name) + assert.deepEqual(result.output, {}, name) + } - assert.notDeepEqual(unsortedExpected, sortedActual) - assert.deepEqual([...unsortedExpected].sort(), sortedActual) + const empty = runBetaPushResolver({ affectedOutput: "[]" }) + assert.equal(empty.status, 0, empty.stderr) + assert.deepEqual(empty.output, { mode: "prepare", has_projects: "false", projects: "" }) + + for (const [name, candidate] of [ + ["Nx exits nonzero", { affectedExit: 42 }], + ["Nx returns malformed JSON", { affectedOutput: "{" }], + ["Nx returns an object", { affectedOutput: "{}" }], + ["Nx returns multiple JSON values", { affectedOutput: "[]\n[]" }], + ["Nx returns a mixed array", { affectedOutput: JSON.stringify([betaProject, 3]) }], + ]) { + const result = runBetaPushResolver(candidate) + assert.notEqual(result.status, 0, name) + assert.deepEqual(result.output, {}, name) + } +}) + +test("dev pushes retain exact-range conditional alpha publication", () => { + assert.deepEqual(channelViolations("alpha", workflows.alpha), []) }) test("beta release message guards classify only the first-line subject", () => { @@ -734,6 +1685,49 @@ test("the release policy contract runs in PR CI", () => { assert.notEqual(commandPosition(workflows.ci, contractCommand), -1) }) +test("stable release PRs require exactly one source commit from actual GitHub PR SHAs", () => { + assert.deepEqual(stableReleasePrGuardViolations(workflows.ci), []) +}) + +test("the stable release PR guard rejects injection and commit-count policy drift", () => { + for (const [name, before, after] of [ + ["write-capable token", "contents: read", "contents: write"], + [ + "extra release-policy OIDC permission", + " permissions:\n contents: read", + " permissions:\n contents: read\n id-token: write", + ], + [ + "broadened branch condition", + "startsWith(github.event.pull_request.head.ref, 'release/stable-')", + "startsWith(github.event.pull_request.head.ref, 'release/')", + ], + [ + "merge-ref checkout", + "ref: ${{ github.event.pull_request.head.sha }}", + "ref: ${{ github.event.pull_request.merge_commit_sha }}", + ], + ["shallow head checkout", "fetch-depth: 0", "fetch-depth: 1"], + ["persisted checkout credentials", "persist-credentials: false", "persist-credentials: true"], + [ + "shell expression interpolation", + 'SOURCE_COMMIT_COUNT=$(git rev-list --count "$PR_BASE_SHA..$PR_HEAD_SHA")', + 'SOURCE_COMMIT_COUNT=$(git rev-list --count "${{ github.event.pull_request.base.sha }}..$PR_HEAD_SHA")', + ], + ["short head SHA", '"$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$', '"$PR_HEAD_SHA" =~ ^[0-9a-f]{7,40}$'], + [ + "unvalidated branch fetch", + 'git fetch --no-tags --no-write-fetch-head origin "$PR_BASE_SHA"', + 'git fetch --no-tags --no-write-fetch-head origin "$PR_HEAD_REF"', + ], + ["three-dot comparison", '"$PR_BASE_SHA..$PR_HEAD_SHA"', '"$PR_BASE_SHA...$PR_HEAD_SHA"'], + ["accept multiple source commits", '"$SOURCE_COMMIT_COUNT" != 1', '"$SOURCE_COMMIT_COUNT" -lt 1'], + ]) { + const changed = mutate(workflows.ci, before, after) + assert.notDeepEqual(stableReleasePrGuardViolations(changed), [], name) + } +}) + test("the Node-only release policy job can bootstrap setup-node without pnpm", () => { assert.deepEqual(releasePolicyBootstrapViolations(workflows.ci), []) @@ -842,7 +1836,7 @@ test("beta FINALIZE conflict and ordering mutations fail closed", () => { "(steps.release.outputs.mode == 'prepare' || steps.release.outputs.mode == 'finalize')", "steps.release.outputs.mode == 'prepare'", ], - ["accept short expected SHA", "^[0-9a-f]{40}$", "^[0-9a-f]{7,40}$"], + ["accept short expected SHA", betaFinalizeExpectedShaGuard, betaFinalizeExpectedShaGuard.replace("{40}", "{7,40}")], ["weaken checkout equality", 'test "$HEAD_SHA" = "$EXPECTED_SHA"', 'test "$HEAD_SHA" != "$EXPECTED_SHA"'], ["weaken remote equality", 'test "$REMOTE_SHA" = "$EXPECTED_SHA"', 'test "$REMOTE_SHA" != "$EXPECTED_SHA"'], ["create lightweight tags", 'git tag -a "$TAG" "$EXPECTED_SHA" -m "$TAG"', 'git tag "$TAG" "$EXPECTED_SHA"'], @@ -912,7 +1906,7 @@ test("protected stable PREFLIGHT rejects authorization and mutation-boundary dri mutateStep( stable, "Resolve exact stable mode", - '[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo \'::error::PREFLIGHT requires full lowercase expected_sha\'; exit 1; }', + "[[ \"$EXPECTED_SHA\" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::PREFLIGHT requires full lowercase expected_sha'; exit 1; }", ":", ), ], @@ -939,15 +1933,15 @@ test("protected stable PREFLIGHT rejects authorization and mutation-boundary dri mutateStep( stable, "PREFLIGHT exact stable artifacts", - "GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}", - "GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}", + "GITHUB_TOKEN: ${{ github.token }}", + "GITHUB_TOKEN: ${{ github.token }}\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}", ), ], [ "skip fresh authorization for PREFLIGHT", mutateStep( stable, - "Fresh master authorization", + "Resolve exact stable mode", 'if [ "$MODE" = preflight ] || [ "$MODE" = finalize ]; then', 'if [ "$MODE" = finalize ]; then', ), @@ -963,24 +1957,68 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" for (const [name, before, after] of [ ["weaken expected SHA", "if (!/^[0-9a-f]{40}$/.test(expectedSha))", "if (!/^[0-9a-f]{7,40}$/.test(expectedSha))"], ["weaken artifact SHA", "if (!/^[0-9a-f]{40}$/.test(artifactSha))", "if (!/^[0-9a-f]{7,40}$/.test(artifactSha))"], + ["remove entry realpath resolution", "resolvedEntry = realpathSync(entry)", "resolvedEntry = entry"], + [ + "remove module URL realpath resolution", + "resolvedModule = realpathSync(fileURLToPath(import.meta.url))", + "resolvedModule = fileURLToPath(import.meta.url)", + ], + [ + "remove main-module URL normalization", + "return pathToFileURL(resolvedEntry).href === pathToFileURL(resolvedModule).href", + "return resolvedEntry === resolvedModule", + ], ["remove expected SHA environment", 'const expectedSha = process.env.EXPECTED_SHA ?? ""', 'const expectedSha = ""'], - ["remove artifact SHA environment", "const artifactSha = process.env.ARTIFACT_SHA || expectedSha", "const artifactSha = expectedSha"], - ["swap expected SHA validation", '.test(expectedSha)) fail("FINALIZE requires full lowercase expected SHA")', '.test(artifactSha)) fail("FINALIZE requires full lowercase expected SHA")'], - ["swap artifact SHA validation", '.test(artifactSha)) fail("FINALIZE requires full lowercase artifact SHA")', '.test(expectedSha)) fail("FINALIZE requires full lowercase artifact SHA")'], + [ + "remove artifact SHA environment", + "const artifactSha = process.env.ARTIFACT_SHA || expectedSha", + "const artifactSha = expectedSha", + ], + [ + "swap expected SHA validation", + '.test(expectedSha)) fail("FINALIZE requires full lowercase expected SHA")', + '.test(artifactSha)) fail("FINALIZE requires full lowercase expected SHA")', + ], + [ + "swap artifact SHA validation", + '.test(artifactSha)) fail("FINALIZE requires full lowercase artifact SHA")', + '.test(expectedSha)) fail("FINALIZE requires full lowercase artifact SHA")', + ], ["authorize HEAD with artifact SHA", "head !== expectedSha", "head !== artifactSha"], ["authorize origin with artifact SHA", "origin !== expectedSha", "origin !== artifactSha"], ["verify remote tags with expected SHA", "peeled[0] === artifactSha", "peeled[0] === expectedSha"], ["verify local tags with expected SHA", "match[1] === artifactSha", "match[1] === expectedSha"], - ["target annotated tags at expected SHA", '["tag", "-a", tag, artifactSha, "-m", tag]', '["tag", "-a", tag, expectedSha, "-m", tag]'], + [ + "target annotated tags at expected SHA", + '["tag", "-a", tag, artifactSha, "-m", tag]', + '["tag", "-a", tag, expectedSha, "-m", tag]', + ], ["remove historical all-existing guard", "if (historicalReplay) {", "if (false) {"], ["weaken historical npm exactness", 'item.npm !== "exact"', 'item.npm === "unknown"'], + ["skip artifact changelog blob verification", "await verifyArtifactChangelog()", ""], + ["accept octopus artifacts", "parents.length !== 2", "parents.length < 2"], + [ + "accept merge second parent not based on first parent", + "generatedParents[0] !== firstParent", + "generatedParents[0] === firstParent", + ], + ["accept a differing merge tree", "treeIds[0] !== treeIds[1]", "treeIds[0] === treeIds[1]"], ["unbound retries", "const maxReads = 6", "const maxReads = 60"], - ["weaken manifest", "value.name !== name || value.version !== version", "false"], - ["accept duplicate tag refs", "direct.length === 1 && peeled.length === 1", "direct.length > 0 && peeled.length > 0"], + [ + "remove publish lifecycle-script environment", + 'env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: "true" }', + "env: process.env", + ], + + [ + "accept duplicate tag refs", + "direct.length === 1 && peeled.length === 1", + "direct.length > 0 && peeled.length > 0", + ], ["accept auth as absence", "result.status === 404", "result.status >= 400"], ["lightweight tags", '["tag", "-a",', '["tag",'], ["non-atomic push", '["push", "--atomic", "origin", ...refs]', '["push", "origin", ...refs]'], - ["publish all projects", 'states.filter((x) => x.npm === "absent")', "states"], + ["publish all projects", 'states.filter((state) => state.npm === "absent")', "states"], ]) { const changed = mutate(stableFinalizeScript, before, after) assert.notDeepEqual(stableViolations(policy.stable, changed), [], name) @@ -995,22 +2033,28 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" } for (const [name, before, after] of [ - ["swap FINALIZE expected SHA env", "EXPECTED_SHA: ${{ inputs.expected_sha }}", "EXPECTED_SHA: ${{ inputs.artifact_sha }}"], - ["swap FINALIZE artifact SHA env", "ARTIFACT_SHA: ${{ inputs.artifact_sha }}", "ARTIFACT_SHA: ${{ inputs.expected_sha }}"], + [ + "swap FINALIZE expected SHA env", + "EXPECTED_SHA: ${{ needs.validate.outputs.expected_sha }}", + "EXPECTED_SHA: ${{ needs.validate.outputs.artifact_sha }}", + ], + [ + "swap FINALIZE artifact SHA env", + "ARTIFACT_SHA: ${{ needs.validate.outputs.artifact_sha }}", + "ARTIFACT_SHA: ${{ needs.validate.outputs.expected_sha }}", + ], ]) { const changed = mutateStep(policy.stable, "FINALIZE exact stable artifacts", before, after) assert.notDeepEqual(stableViolations(changed), [], name) } - const prepareJson = mutateStep(policy.stable, "PREPARE protected stable", /JSON\.parse/g, "JSON.parseSafe") - assert.ok(stableViolations(prepareJson).includes("stable PREPARE Node JSON type validation")) - const prepareShadow = mutateStep(policy.stable, "PREPARE protected stable", /read -r NAME MANIFEST_PATH/, "read -r NAME PATH") - assert.ok(stableViolations(prepareShadow).includes("stable PREPARE reserved PATH shadowing")) - const prepareArgument = mutateStep(policy.stable, "PREPARE protected stable", /"\$MANIFEST_PATH" "\$NAME"/g, '"$PATH" "$NAME"') - assert.ok(stableViolations(prepareArgument).includes("stable PREPARE MANIFEST_PATH manifest command")) - - const shortSha = mutate(policy.stable, "^[0-9a-f]{40}$", "^[0-9a-f]{7,40}$") - assert.notDeepEqual(stableViolations(shortSha), [], "allow abbreviated PREPARE SHA") + const shortSha = mutateStep( + policy.stable, + "Resolve exact stable mode", + stableFinalizeExpectedShaGuard, + stableFinalizeExpectedShaGuard.replace("{40}", "{7,40}"), + ) + assert.notDeepEqual(stableViolations(shortSha), [], "allow abbreviated FINALIZE SHA") for (const [name, before, after] of [ ["enable Nx commits", "--git-commit=false", "--git-commit=true"], @@ -1019,27 +2063,128 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" ["enable Nx staging", "--stage-changes=false", "--stage-changes=true"], ["weaken path comparison", 'cmp -s "$EXPECTED_PATHS" "$ACTUAL"', 'test -s "$ACTUAL"'], ["stage broad tree", 'git add --pathspec-from-file="$EXPECTED_PATHS"', "git add -A"], - ["weaken staged paths", 'cmp -s "$EXPECTED_PATHS" /tmp/stable-staged', 'test -s /tmp/stable-staged'], - ["push master", "HEAD:refs/heads/release/stable-$SHA_PREFIX", "HEAD:refs/heads/master"], + ["weaken staged paths", 'cmp -s "$EXPECTED_PATHS" "$STAGED_PATHS"', 'test -s "$STAGED_PATHS"'], + [ + "accept a non-blob prepared changelog", + '[ "$RELEASE_CHANGELOG_TYPE" != "blob" ]', + '[ -z "$RELEASE_CHANGELOG_TYPE" ]', + ], ]) { const changed = mutateStep(policy.stable, "PREPARE protected stable", before, after) assert.notDeepEqual(stableViolations(changed), [], name) } + + const uncheckedArtifactChangelog = mutateStep( + policy.stable, + "Resolve exact stable mode", + 'git cat-file -t "$RESOLVED_ARTIFACT_SHA:CHANGELOG.md"', + 'git cat-file -t "$HEAD_SHA:CHANGELOG.md"', + ) + assert.notDeepEqual(stableViolations(uncheckedArtifactChangelog), [], "validate a different changelog artifact") + + const pushMaster = mutateStep( + policy.stable, + "Push protected stable branch", + 'push origin "HEAD:refs/heads/$BRANCH"', + 'push origin "HEAD:refs/heads/master"', + ) + assert.notDeepEqual(stableViolations(pushMaster), [], "push master") +}) + +test("stable mode jobs compare exact permissions independent of mapping order", () => { + const reversedFinalizePermissions = mutate( + workflows.stable, + " permissions:\n contents: write\n id-token: write", + " permissions:\n id-token: write\n contents: write", + ) + assert.deepEqual(stableCapabilityViolations(reversedFinalizePermissions), []) + + const extraFinalizePermission = mutate( + workflows.stable, + " permissions:\n contents: write\n id-token: write", + " permissions:\n contents: write\n id-token: write\n issues: read", + ) + assert.ok(stableCapabilityViolations(extraFinalizePermission).includes("stable finalize least privilege")) +}) + +test("stable mode jobs reject capability and credential drift", () => { + for (const [name, before, after] of [ + [ + "validation write permission", + " validate:\n name: 🔎 Validate stable request\n runs-on: ubuntu-latest\n permissions:\n contents: read", + " validate:\n name: 🔎 Validate stable request\n runs-on: ubuntu-latest\n permissions:\n contents: write", + ], + ["persist checkout credentials", "persist-credentials: false", "persist-credentials: true"], + ["remove protected environment", "environment: stable-release", "environment: unprotected"], + [ + "enable FINALIZE lifecycle scripts", + " - name: 📦 Install publication tooling without lifecycle scripts\n run: pnpm install --frozen-lockfile --ignore-scripts", + " - name: 📦 Install publication tooling without lifecycle scripts\n run: pnpm install --frozen-lockfile", + ], + ["remove FINALIZE lifecycle-script environment", " NPM_CONFIG_IGNORE_SCRIPTS: true\n", ""], + [ + "give PREFLIGHT OIDC", + " preflight:\n name: 🔎 PREFLIGHT exact stable artifacts\n needs: validate\n if: ${{ needs.validate.outputs.mode == 'preflight' }}\n runs-on: ubuntu-latest\n permissions:\n contents: read", + " preflight:\n name: 🔎 PREFLIGHT exact stable artifacts\n needs: validate\n if: ${{ needs.validate.outputs.mode == 'preflight' }}\n runs-on: ubuntu-latest\n permissions:\n contents: read\n id-token: write", + ], + ]) { + assert.notDeepEqual(stableViolations(mutate(workflows.stable, before, after)), [], name) + } +}) + +test("stable transition SemVer rejects leading-zero identifiers", () => { + const pattern = new RegExp(stableTransitionVersionPattern) + for (const version of ["0.0.0-beta.0", "1.2.3-beta.4", "10.20.30-beta.40"]) { + assert.equal(pattern.test(version), true, version) + } + for (const version of ["01.2.3-beta.4", "1.02.3-beta.4", "1.2.03-beta.4", "1.2.3-beta.04"]) { + assert.equal(pattern.test(version), false, version) + } }) -test("stable suppression rejects path, transition, and message-only mutations", () => { +test("the beta classifier contract rejects a post-marker function redefinition", () => { + assert.deepEqual(classifierStructureViolations(workflows.beta), []) + const redefined = mutate( + workflows.beta, + classifierInvocation, + `classify_push_shape() {\n printf '%s\\n' prepare\n }\n ${classifierInvocation}`, + ) + const violations = classifierStructureViolations(redefined) + assert.ok(violations.includes("beta exactly one classifier declaration")) + assert.ok(violations.includes("beta classifier invocation immediately follows end marker")) + assert.notDeepEqual(betaViolations(redefined), []) +}) + +test("generic stable suppression rejects path, transition, and message-only mutations", () => { const policy = { ...workflows, docs: readme } + const versionViolation = "beta stable structural check no-leading-zero beta source" + assert.equal(betaViolations(policy.beta).includes(versionViolation), false) + const leadingZeroMutation = mutate( + policy.beta, + stableTransitionVersionPattern, + "^([0-9]+)\\.([0-9]+)\\.([0-9]+)-beta\\.([0-9]+)$", + ) + assert.equal(betaViolations(leadingZeroMutation).includes(versionViolation), true) + for (const [name, before, after] of [ - ["omit changelog", "CHANGELOG.md packages/hatchet", "packages/hatchet"], - ["add path", "packages/solid/query/package.json | sort", "README.md packages/solid/query/package.json | sort"], - ["alter source", "0.1.0-beta.0=0.1.0|packages/hatchet", "0.1.0-beta.1=0.1.0|packages/hatchet"], - ["alter target", "1.0.0-beta.1=1.0.0|packages/react/query", "1.0.0-beta.1=1.0.1|packages/react/query"], - ["ignore old JSON", 'OLD_VERSION=$(git show "$BASE:$MANIFEST_PATH" | jq -er .version)', "OLD_VERSION=$OLD"], - ["ignore new JSON", 'NEW_VERSION=$(jq -er .version "$MANIFEST_PATH")', "NEW_VERSION=$NEW"], - ["restore reserved PATH loop binding", "read -r TRANSITION MANIFEST_PATH", "read -r TRANSITION PATH"], + ["ignore root changelog", "HAS_CHANGELOG=true", "HAS_CHANGELOG=false"], + ["accept extra path", "UNEXPECTED=true", "UNEXPECTED=false"], + ["ignore old reviewed JSON", 'git show "$BASE:$MANIFEST_PATH"', 'git show "$HEAD:$MANIFEST_PATH"'], + ["ignore new reviewed JSON", 'git show "$HEAD:$MANIFEST_PATH"', 'git show "$BASE:$MANIFEST_PATH"'], + ["remove old manifest JSON cardinality", oldManifestCardinalityGuard, "if false ||"], + ["remove new manifest JSON cardinality", newManifestCardinalityGuard, "false; then"], + [ + "allow package rename", + '[ "$OLD_NAME" != "$NAME" ] || [ "$NEW_NAME" != "$NAME" ]', + '[ -z "$OLD_NAME" ] && [ -z "$NEW_NAME" ]', + ], + ["allow arbitrary metadata version", '[ "$OLD_VERSION" = "$NEW_VERSION" ]', '[ -n "$NEW_VERSION" ]'], + ["allow unrelated stable target", '[ "$NEW_VERSION" = "$STABLE_VERSION" ]', '[ -n "$NEW_VERSION" ]'], + ["remove non-benign manifest guard", releaseManifestGuard, "if false; then"], + ["accept an unknown classifier result", classificationFailClosedGuard, 'if [ "$CLASSIFICATION" = "reject" ]; then'], [ "message authorizes suppression", - 'if cmp -s "$EXPECTED_PATHS" "$CHANGED"; then', + exactBetaSuppressionGuard, 'if [[ "$HEAD_MESSAGE" == *"[skip release]"* ]]; then', ], ]) @@ -1049,33 +2194,71 @@ test("stable suppression rejects path, transition, and message-only mutations", })) }) -test("protected stable documentation rejects authorization and recovery drift", () => { - for (const [name, before, after] of [ - ["manual PR", "manually open its linked PR", "automatically open a PR"], - ["protected checks", "Required checks, review, and branch protection authorize merge", "PREPARE authorizes merge"], - [ - "channel policy", - "Alpha remains prerelease-only with `--tag=alpha`; beta remains prerelease-only", - "Alpha and beta may use latest", - ], - ["same identity retry", "Retry only the same exact SHA and matrix", "Retry with a new SHA"], - ["stop conditions", "**Stop immediately**", "Continue automatically"], - ["forward recovery", "never delete, retarget, unpublish, deprecate, or rewrite it", "delete conflicting artifacts"], - ]) { - const changed = mutate(setup, before, after) - const required = [ - "manually open its linked PR", - "Required checks, review, and branch protection authorize merge", - "Alpha remains prerelease-only with `--tag=alpha`; beta remains prerelease-only", - "Retry only the same exact SHA and matrix", - "**Stop immediately**", - "never delete, retarget, unpublish, deprecate, or rewrite it", - ] - assert.ok( - required.some((text) => !changed.includes(text)), - name, - ) - } +test("protected stable documentation exposes authorization and recovery boundaries", () => { + for (const required of [ + "second reviewed release PR", + "manually open its linked PR", + "Required checks, review, and branch protection authorize merge", + "Alpha remains prerelease-only with `--tag=alpha`; beta remains prerelease-only", + "Retry only the same exact SHAs and selected subset", + "FINALIZE is refused outside GitHub Actions", + "PREPARE creates exactly one local commit", + "read-only release-policy CI guard", + "Extra source commits cannot pass this release PR gate", + "FINALIZE supports merge commits and squashes", + "Rebase merge is supported only for the single PREPARE commit", + "squash or single-commit rebase produces a one-parent artifact", + "exactly two parents", + "single generated release commit based directly on that first parent", + "merge tree exactly matches the second-parent tree", + "aggregate first-parent diff", + "strict one-parent and exact two-parent graph validation", + "cannot distinguish a squash from the last commit produced by a rebase", + "does not infer whether preceding source commits existed", + "octopus merges", + "**Stop immediately**", + "never delete, retarget, unpublish, deprecate, or rewrite it", + ]) + assert.match(setup, new RegExp(required.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"))) + + assert.match( + setup, + /Declared job permissions, including `contents` and `id-token`, are available job-wide; when `id-token: write` is declared, OIDC is not step-scoped\./, + ) + assert.match( + setup, + /The only step-scoped credential controls are explicit secret or token environment variables on their listed API or mutation steps\./, + ) + assert.match(setup, /Every checkout sets `persist-credentials: false`, so checkout credentials are not persisted\./) + assert.match( + setup, + /The real stable publication boundary is protected `stable-release` environment review, authorization of the reviewed SHA, and npm trusted publishing bound to the repository, workflow, environment, and OIDC claims\./, + ) + assert.match( + setup, + /Action references currently use reviewed moving major tags and are not immutable; this remains a supply-chain risk unless and until repository-wide commit-SHA pinning is adopted\./, + ) + assert.doesNotMatch(setup, /immutable action versions as referenced/i) + assert.match(setup, /`GITHUB_ACTIONS` is checked only as an accidental-use guard/) + assert.equal(setup.match(/GITHUB_ACTIONS/g)?.length, 1) + + assert.match( + setup, + /gh pr create --base master --head "\$STABLE_BRANCH" --title "chore\(release\): promote stable" --body "Closes #\$ISSUE" --label "type:chore"/, + ) + assert.match( + setup, + /EXPECTED_SHA=\$\(gh api repos\/\{owner\}\/\{repo\}\/git\/ref\/heads\/master --jq '\.object\.sha'\)/, + ) + assert.match(setup, /ARTIFACT_SHA=\$\(gh pr view "\$STABLE_PR" --json mergeCommit --jq '\.mergeCommit\.oid'\)/) + assert.match( + setup, + /gh workflow run release-stable\.yml --ref master[\s\S]*-f preflight_only=true[\s\S]*-f expected_sha="\$EXPECTED_SHA"[\s\S]*-f artifact_sha="\$ARTIFACT_SHA"/, + ) + assert.match( + setup, + /gh workflow run release-stable\.yml --ref master[\s\S]*-f publish_only=true[\s\S]*-f preflight_only=false[\s\S]*-f expected_sha="\$EXPECTED_SHA"[\s\S]*-f artifact_sha="\$ARTIFACT_SHA"/, + ) }) test("protected stable promotion exposes exact PREPARE and FINALIZE contracts", () => { @@ -1088,11 +2271,13 @@ test("protected stable promotion exposes exact PREPARE and FINALIZE contracts", assert.match(active, /historical replay requires exact existing tag, GitHub Release, and npm latest/) assert.match(active, /MODE=prepare/) assert.match(active, /MODE=finalize/) + assert.match(active, /jq -r ['"]?\.release\.projects\[\]['"]? nx\.json/) + assert.match(active, /pnpm nx show project "\$RELEASE_ROOT" --json/) assert.match( active, - /pnpm nx release version "\$NEW" "--projects=\$NAME" --git-commit=false --git-tag=false --git-push=false --stage-changes=false/, + /pnpm nx release version "\$NEW" "--projects=\$PROJECT" --git-commit=false --git-tag=false --git-push=false --stage-changes=false/, ) - assert.match(active, /HEAD:refs\/heads\/release\/stable-\$SHA_PREFIX/) + assert.match(active, /push origin "HEAD:refs\/heads\/\$BRANCH"/) assert.match(active, /run\("git", \["push", "--atomic", "origin", \.\.\.refs\]\)/) assert.match(active, /github\("POST", "\/releases"/) assert.match(active, /run\("pnpm", \["nx", "release", "publish"/) @@ -1102,45 +2287,28 @@ test("protected stable promotion exposes exact PREPARE and FINALIZE contracts", assert.match(active, /await sleep\(delayMs\)/) }) -test("beta structurally suppresses only the exact stable matrix", () => { +test("beta structurally separates benign metadata from exact reviewed beta-to-stable subsets", () => { const active = withoutComments(workflows.beta) - for (const transition of [ - "@effectify/hatchet=0.1.0-beta.0=0.1.0", - "@effectify/node-better-auth=0.5.12-beta.0=0.5.12", - "@effectify/prisma=1.1.13-beta.0=1.1.13", - "@effectify/react-query=1.0.0-beta.1=1.0.0", - "@effectify/react-router=0.6.0-beta.0=0.6.0", - "@effectify/react-router-better-auth=0.5.12-beta.0=0.5.12", - "@effectify/solid-query=0.5.13-beta.0=0.5.13", - ]) - assert.match(active, new RegExp(transition.replaceAll("/", "\\/"))) + assert.match(active, /jq -r ['"]?\.release\.projects\[\]['"]? nx\.json/) + assert.match(active, /git show "\$BASE:\$MANIFEST_PATH"/) + assert.match(active, /git show "\$HEAD:\$MANIFEST_PATH"/) + assert.match(active, /BETA_TRANSITIONS/) + assert.match(active, /BENIGN_MANIFEST_CHANGES/) + assert.match(active, /INVALID_MANIFESTS/) + assert.match(active, /MANIFEST_CHANGES/) assert.match(active, /stable promotion shape is partial, mixed, or malformed/) + assert.doesNotMatch(active, /STABLE_TRANSITIONS|@effectify\/hatchet=0\.1\.0-beta\.0=0\.1\.0/) }) -test("corrective solid-query beta and updated stable matrix are exact", () => { +test("beta manual PREPARE remains dynamic without a completed corrective matrix", () => { const beta = withoutComments(workflows.beta) - const stable = withoutComments(workflows.stable) - assert.match(beta, /manual PREPARE requires all seven release projects or the corrective solid-query singleton/) - assert.match(beta, /echo "version_specifier=prepatch" >> "\$GITHUB_OUTPUT"/) - assert.match(beta, /pnpm nx release version \$VERSION_SPECIFIER "--projects=\$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false/) - assert.match(beta, /0\.5\.12-beta\.0=0\.5\.13-beta\.0\|packages\/solid\/query\/package\.json/) - assert.match(beta, /CHANGELOG\.md packages\/solid\/query\/package\.json \| sort > "\$CORRECTIVE_PATHS"/) - assert.match(stable, /@effectify\/solid-query\|packages\/solid\/query\/package\.json\|0\.5\.13-beta\.0\|0\.5\.13/) - assert.doesNotMatch(stable, /@effectify\/solid-query\|packages\/solid\/query\/package\.json\|0\.5\.12-beta\.0\|0\.5\.12/) - - for (const [name, before, after, required] of [ - ["arbitrary singleton", 'elif [ "$SELECTED_PROJECTS" = "@effectify/solid-query" ]', 'elif [ "$SELECTED_PROJECTS" = "@effectify/react-query" ]', /SELECTED_PROJECTS" = "@effectify\/solid-query/], - ["prerelease specifier", "version_specifier=prepatch", "version_specifier=prerelease", /version_specifier=prepatch/], - ["wrong target", "CORRECTIVE_TRANSITION='@effectify/solid-query=0.5.12-beta.0=0.5.13-beta.0", "CORRECTIVE_TRANSITION='@effectify/solid-query=0.5.12-beta.0=0.5.14-beta.0", /CORRECTIVE_TRANSITION='@effectify\/solid-query=0\.5\.12-beta\.0=0\.5\.13-beta\.0/], - ["wrong counter", "CORRECTIVE_TRANSITION='@effectify/solid-query=0.5.12-beta.0=0.5.13-beta.0", "CORRECTIVE_TRANSITION='@effectify/solid-query=0.5.12-beta.0=0.5.13-beta.1", /CORRECTIVE_TRANSITION='@effectify\/solid-query=0\.5\.12-beta\.0=0\.5\.13-beta\.0/], - ["broad paths", "CHANGELOG.md packages/solid/query/package.json | sort", "CHANGELOG.md README.md packages/solid/query/package.json | sort", /CHANGELOG\.md packages\/solid\/query\/package\.json \| sort/], - ["message-only", 'if cmp -s "$CORRECTIVE_PATHS" "$CHANGED"; then', 'if [[ "$HEAD_MESSAGE" == *"[skip release]"* ]]; then', /cmp -s "\$CORRECTIVE_PATHS" "\$CHANGED"/], - ]) { - const mutated = mutate(beta, before, after) - assert.doesNotMatch(mutated, required, name) - } - const oldStable = mutate(stable, "0.5.13-beta.0|0.5.13", "0.5.12-beta.0|0.5.12") - assert.doesNotMatch(oldStable, /0\.5\.13-beta\.0\|0\.5\.13/) + assert.match(beta, /echo "version_specifier=" >> "\$GITHUB_OUTPUT"/) + assert.match( + beta, + /pnpm nx release version \$VERSION_SPECIFIER "--projects=\$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false/, + ) + assert.doesNotMatch(beta, /CORRECTIVE_|EXPECTED_MATRIX|version_specifier=prepatch|manual PREPARE requires all seven/) + assert.doesNotMatch(beta, /printf '%s\\n' '@effectify\/[^']+=\d+\.\d+\.\d+-beta/) }) test("alpha and beta exact-range and membership mutations fail closed", () => { @@ -1180,4 +2348,25 @@ test("alpha and beta exact-range and membership mutations fail closed", () => { ), })) } + + for (const [name, before, after] of [ + ["weakens the before SHA", '[[ "$BEFORE_SHA" =~ ^[0-9a-f]{40}$ ]]', '[[ "$BEFORE_SHA" =~ ^[0-9a-f]{7,40}$ ]]'], + ["ignores the event before SHA", 'BEFORE="$BEFORE_SHA"', 'BEFORE="HEAD^"'], + ["ignores the event head SHA", 'HEAD="$HEAD_SHA"', 'HEAD="HEAD"'], + [ + "restores an Nx nonzero fallback", + 'AFFECTED_RAW=$(pnpm nx show projects --affected --base="$BASE" --head="$HEAD" --json)', + 'AFFECTED_RAW=$(pnpm nx show projects --affected --base="$BASE" --head="$HEAD" --json || echo "[]")', + ], + [ + "removes the affected JSON string-array contract", + 'printf \'%s\' "$AFFECTED_RAW" | jq -e -s \'length == 1 and (.[0] | type == "array" and all(.[]; type == "string"))\' >/dev/null', + ":", + ], + ]) { + assertMutationFails(`beta ${name}`, policy, (candidate) => ({ + ...candidate, + beta: mutate(candidate.beta, before, after), + })) + } }) diff --git a/tools/release-version-actions.test.cjs b/tools/release-version-actions.test.cjs index 88f388f6..010c4d3f 100644 --- a/tools/release-version-actions.test.cjs +++ b/tools/release-version-actions.test.cjs @@ -16,19 +16,16 @@ const releasedPackages = [ { name: "@effectify/prisma", version: "1.0.0" }, ] -const stableMatrix = [ - ["@effectify/hatchet", "0.1.0", []], - ["@effectify/node-better-auth", "0.5.12", []], - ["@effectify/prisma", "1.1.13", []], - ["@effectify/react-query", "1.0.0", []], - ["@effectify/react-router", "0.6.0", ["0.5.10"]], - ["@effectify/react-router-better-auth", "0.5.12", []], - ["@effectify/solid-query", "0.5.13", ["0.5.12"]], +const independentCandidates = [ + ["@future/nebula", "packages/future/nebula", "4.7.0", []], + ["@future/orbit", "packages/future/orbit", "8.0.1", ["8.0.0"]], + ["@future/quasar", "packages/future/quasar", "12.3.5", ["12.3.4"]], ] -test("no-network stable actions accept the exact heterogeneous matrix and reject an exact target collision", async () => { +test("no-network stable actions accept arbitrary independent candidates and reject an exact target collision", async () => { const delegated = [] const delegatedUpdates = [] + const manifestReads = [] class BaseVersionActions { constructor(releaseGroup, projectGraphNode, finalConfigForProject) { this.releaseGroup = releaseGroup @@ -45,39 +42,58 @@ test("no-network stable actions accept the exact heterogeneous matrix and reject } } - const run = async ([name, candidate, publishedVersions]) => { + const run = async ([name, projectRoot, candidate, publishedVersions]) => { const VersionActions = createCollisionAwareVersionActions({ BaseVersionActions, resolveRegistry: async () => "https://registry.example.test/", getPublishedVersions: async () => publishedVersions, }) - const action = new VersionActions({}, { data: { root: name.slice("@effectify/".length) } }, { - candidate, - versionActionsOptions: {}, + const action = new VersionActions( + {}, + { data: { root: projectRoot } }, + { + candidate, + versionActionsOptions: {}, + }, + ) + await action.init({ + root: "/repo", + read: (manifestPath) => { + manifestReads.push(manifestPath) + assert.equal(manifestPath, `${projectRoot}/package.json`) + return Buffer.from(JSON.stringify({ name, version: "0.0.0" })) + }, }) - await action.init({ root: "/repo", read: () => Buffer.from(JSON.stringify({ name, version: "0.0.0" })) }) return action.calculateNewVersion("0.0.0", candidate, "exact stable", {}, "") } const accepted = [] - for (const record of stableMatrix) accepted.push(await run(record)) - assert.deepEqual(accepted.map(({ newVersion }) => newVersion), stableMatrix.map(([, version]) => version)) - assert.equal(delegated.length, 7) + for (const record of independentCandidates) accepted.push(await run(record)) + assert.deepEqual( + accepted.map(({ newVersion }) => newVersion), + independentCandidates.map(([, , version]) => version), + ) + assert.deepEqual( + manifestReads, + independentCandidates.map(([, root]) => `${root}/package.json`), + ) + assert.equal(delegated.length, independentCandidates.length) const beforeCollision = delegated.length await assert.rejects( - () => run(["@effectify/solid-query", "0.5.13", ["0.5.12", "0.5.13"]]), - /stable candidate 0\.5\.13 is already published/, + () => run(["@future/quasar", "packages/future/quasar", "12.3.5", ["12.3.4", "12.3.5"]]), + /stable candidate 12\.3\.5 is already published/, ) + assert.equal(manifestReads.at(-1), "packages/future/quasar/package.json") assert.equal(delegated.length, beforeCollision + 1) assert.deepEqual(delegatedUpdates, []) }) -test("cumulative seven-package root changelog is idempotent and dry-run updates never write", () => { - const packages = stableMatrix.map(([name, version]) => ({ name, version })) +test("cumulative arbitrary-subset root changelog is idempotent and dry-run updates never write", () => { + const packages = independentCandidates.map(([name, , version]) => ({ name, version })) const date = new Date("2026-07-12T00:00:00Z") const changelog = mergeRootChangelog(undefined, packages, date) - assert.equal((changelog.match(/^## @effectify\//gm) ?? []).length, 7) + assert.equal((changelog.match(/^## @future\//gm) ?? []).length, independentCandidates.length) assert.equal(mergeRootChangelog(changelog, packages, date), changelog) assert.deepEqual(getRootChangelogUpdate("# stale\n", changelog, true), { changed: true,