Skip to content
systemfsoftwarePublic

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

starter

License: Apache-2.0 Effect: 4.0 CI

πŸ›οΈ starter is an opinionated monorepo template for serious TypeScript with Effect and AI coding agents. πŸ”’ One architecture, zero knobs, and mechanical gates that reject the slop agents produce when left unconstrained. πŸš€ Built for engineers accountable for codebases where AI writes the commits.


πŸ’‘ Why

AI coding agents produce TypeScript that compiles cleanly and passes shallow unit tests while quietly violating foundational architecture: ambient side effects inside decision logic, unchecked type assertions (as Type), and mock-heavy test suites that mask runtime breakage.

starter establishes an uncompromising substrate. Invariants are not aspirational guidelines or doc comments; they are enforced mechanically by linter rules, complexity ceilings, mutation test floors, and continuous integration gates.

Concern The Naive AI-Assisted Default The Endgame Architecture (starter)
Domain Logic ❌ Interleaved I/O, clocks, random generators, and mutations βœ… Pure functions returning tagged Decision or Refusal unions
Branching ❌ Sprawling nested if/else and procedural loops βœ… Cyclomatic complexity 1 via exhaustive pattern matching (Match)
Boundary Data ❌ Unchecked casts (as unknown as Type, @ts-ignore) βœ… Strict Schema.decode transforming raw bytes into branded types
Test Verification ❌ Mock-heavy tests pinning internal implementation βœ… 100% mutation kill floor (Stryker) and property-based tests
Configuration ❌ Dozens of toggles that let agents bypass strictness βœ… Zero knobs β€” one proven opinionated toolchain end to end
Package Entries ❌ Star exports (export *) hiding dependency graphs βœ… Explicit named re-exports enumerated one line per symbol
Refactoring ❌ Patching around rotten legacy modules βœ… Delete-first rebuild with published observable pinning

πŸ“ Architecture

Every external interaction in a starter project follows the I/O Sandwich:

read (impure) ──► decode (pure) ──► decide (pure) ──► shape (pure) ──► write (impure)
  1. πŸ“₯ read β€” Gathers raw input from ports and external systems.
  2. πŸ” decode β€” Validates unvalidated input into branded domain types using Schema.
  3. 🧠 decide β€” Executes domain logic with cyclomatic complexity 1 (zero I/O, zero ambient state).
  4. πŸ“¦ shape β€” Builds pure output documents and domain events from the decision.
  5. πŸ“€ write β€” Persists changes, emits domain events, or returns responses.

🧰 Toolchain

starter wires a modern, fast, and type-safe toolchain across the workspace:

Tool Role & Configuration
⚑ pnpm Workspaces Strict workspace dependency management with catalog versioning (pnpm-workspace.yaml)
🏎️ Turbo High-performance task pipeline with cached builds, tests, and lint runs
πŸ›‘οΈ Effect 4 The standard functional effect system
πŸ” oxlint Rust-based linter enforcing strict TypeScript rules and the house presets
🎨 dprint Fast, deterministic code and markdown formatting (dprint.json)
πŸ§ͺ Vitest Fast unit and integration test runner with TypeScript support
πŸ”¬ Stryker Mutation testing ensuring tests fail when bugs are introduced
πŸ“ Changesets Automated versioning and changelogs, released as git tags via shared tooling
πŸͺ Husky & Commitlint Git hooks enforcing conventional commit standards
🌳 Worktrunk Scripts Deno-powered git worktree lifecycle hooks for isolated agent work

πŸ“ Workspaces

The repository is structured into two workspace roots defined in pnpm-workspace.yaml:

.
β”œβ”€β”€ packages/           # Reusable libraries, engines, and domain cores
β”œβ”€β”€ apps/               # The Worker and its end-to-end journeys
β”œβ”€β”€ repos/              # Vendored subtrees (constitution, worktrunk-scripts)
└── docs/               # Solutions, tooling decisions, and plans
  • apps/site β€” The one Cloudflare Worker: a TanStack Start site that calls its Worker through effect/rpc (one RpcGroup served at /api/rpc, a typed RpcClient in the page) over one D1 database, defined and deployed with Alchemy. pnpm dev emulates the database locally; the health procedure probes the database and answers ok only when the pure decision check-health.workflow.ts finds the probe answered, refusing with DatabaseUnreachable otherwise.
  • apps/site-e2e β€” The end-to-end journeys that run against pnpm dev (pnpm journeys).

πŸš€ Getting Started

1. Create a Repository from Template

Click the Use this template button on GitHub, or create a repository via the GitHub CLI:

gh repo create my-effect-project --template systemfsoftware/starter --public
cd my-effect-project

2. Enter the Dev Shell and Install

Installs, builds, tests and git hooks run their dependency code inside a deny-by-default sandbox, never directly on your machine. The Nix dev shell pins the toolchain and carries the sandbox (prerequisites):

nix develop # or: direnv allow
pnpm bootstrap

3. Build and Verify

pnpm build
pnpm check:ci

4. Run It Locally

pnpm dev       # the whole app at http://localhost:1337, Alchemy's local emulation, no cloud
pnpm journeys  # the end-to-end journeys in a real browser against pnpm dev

The site ships one example feature, a guestbook at /guestbook: a pure decision (sign-guestbook.workflow.ts, built with Workflow.make) trims a name and a message and refuses them with typed errors; the RPC procedures sign and list write and read entries in D1, with the decision's tagged refusals as sign's error schema; and the page shows the entries or the refusal. Any visitor can flag or vouch for an entry: a second decision (moderate-guestbook-entry.workflow.ts) applies the event to the entry's stored state (Visible, Flagged or Hidden) with the pure transition of a machine from our XState fork, and refuses an event the state can't take. The moderate procedure writes the new state only if the row still holds the state it read, and list never returns a Hidden entry. Everything it owns lives in apps/site/src/features/guestbook and apps/site-e2e/tests/features/guestbook.

To remove it:

  1. Delete apps/site/src/features/guestbook.
  2. Delete apps/site-e2e/tests/features/guestbook.
  3. Delete the route file apps/site/src/routes/guestbook.tsx.
  4. apps/site/src/api/site-rpcs.ts: drop the GuestbookRpcs import and make SiteRpcs just HealthRpcs.
  5. apps/site/src/api/site-rpc-server.ts: drop the GuestbookHandlers import and its Layer.provide(GuestbookHandlers) line.
  6. apps/site/alchemy.run.ts: drop the migrations option from Cloudflare.D1.Database('Database', …).
  7. pnpm-workspace.yaml: drop the @systemfsoftware/xstate line under catalog: and its mirror under overrides:; apps/site/package.json: drop the @systemfsoftware/xstate dependency.
  8. Run pnpm install --no-frozen-lockfile from the shell you already have open. It rewrites pnpm-lock.yaml without its xstate entries; --frozen-lockfile (pnpm's default when CI is set) refuses that rewrite. The devshell builds its pnpm store from pnpm-lock.yaml, so it can't start again until the lockfile drops xstate.
  9. flake.nix: drop the systemfsoftware-xstate input, its name in the outputs arguments, and its place in the extraDepsOf copy loop; then run nix flake lock.
  10. Delete this README's guestbook text, from "The site ships one example feature" through the D1 note below.

Removal leaves a deployed D1 as it is: the guestbook_entries table and its 0001_create_guestbook_entries.sql and 0002_add_guestbook_entry_state.sql rows in __alchemy_migrations stay; drop them from the D1 console in the Cloudflare dashboard with DROP TABLE guestbook_entries; DELETE FROM __alchemy_migrations WHERE name IN ('0001_create_guestbook_entries.sql', '0002_add_guestbook_entry_state.sql');.

5. Make the Gates Block Merges

A copy gets the template's files, not its repository settings, so nothing stops a pull request with failing checks from merging until main has a ruleset that requires them. Install the "gates" ruleset from .github/rulesets/gates.json once, with your own GitHub credentials:

gh api -X POST repos/{owner}/{repo}/rulesets --input .github/rulesets/gates.json

When the checks a ruleset requires change, update the installed ruleset instead of creating a second one: PUT the file to the ruleset's id (gh api repos/{owner}/{repo}/rulesets/<id> -X PUT --input .github/rulesets/gates.json, id from gh api repos/{owner}/{repo}/rulesets), then run pnpm check:branch-rules to confirm the live ruleset and scripts/check-branch-rules.ts agree. A pull request whose checks were renamed hangs on the old contexts until it is applied.

Until it is installed, nothing stops a pull request with failing checks from merging. The ruleset requires the single check job.

6. Deploy

Your copy deploys to your own Cloudflare account; the template holds no credentials. Set CLOUDFLARE_API_TOKEN (a token that can edit Workers and D1) and CLOUDFLARE_ACCOUNT_ID, and optionally SITE_DOMAIN (a hostname in a zone on that account) to serve production there instead of on workers.dev:

pnpm run deploy                         # stage prod
ALCHEMY_STAGE=pr-12 pnpm run deploy     # a preview stage
ALCHEMY_STAGE=pr-12 pnpm run destroy

pnpm run deploy, not pnpm deploy: the bare form is pnpm's own built-in command.

In a copy that has run the section 7 bootstrap and set the repository variable ALCHEMY_DEPLOY to true (plus SITE_DOMAIN as a repository variable to serve production on a domain), every same-repo pull request gets a preview deploy with its URL in a comment and its stage destroyed on close, and main deploys production in one job with no gate in front of it. The template itself never deploys.

7. Credentials for CI

The operator runs this once per repository that opts in to deploys. It runs on the host, outside every sandbox (not through bin/cloud or sandbox --), and never on the template. It mints a scoped Cloudflare token and writes it to the repository as secrets, so no one hand-copies a token into repository settings.

The bootstrap requires SITE_DOMAIN: a hostname in a zone on the account, because the CI token's zone grants are scoped to that zone. A copy that serves only on workers.dev skips the bootstrap and creates the CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID repository secrets by hand instead.

From apps/site in the dev shell, with the operator's own secrets (supplied from Doppler, key named by the operator; never print them):

cd apps/site
CLOUDFLARE_API_TOKEN=… CLOUDFLARE_ACCOUNT_ID=… GITHUB_ACCESS_TOKEN=… \
  GITHUB_REPOSITORY=<owner>/<repo> SITE_DOMAIN=<hostname> \
  pnpm exec alchemy deploy --config stacks/github.ts --stage ci-credentials
  • CLOUDFLARE_API_TOKEN: an admin-scoped Cloudflare token that can create API tokens. A standard Edit Workers token is not enough; use an account API token with Account > API Tokens > Write, or the Global API Key.
  • CLOUDFLARE_ACCOUNT_ID: the target account.
  • GITHUB_ACCESS_TOKEN: a fine-grained personal access token on that one repository with only Secrets: write, and a short expiry.
  • GITHUB_REPOSITORY: <owner>/<repo> of the repository that receives the secrets.
  • SITE_DOMAIN: a hostname in a zone on the account. The CI token's zone grants are limited to that zone.

These environment credentials win over any alchemy profile, so no --profile flag is needed. The fixed ci-credentials stage means a rerun updates the same token and secrets instead of minting new ones. A rerun changes the token's grants in place and keeps its value.

The stack is named github-<owner>-<repo> from GITHUB_REPOSITORY, so every repository's bootstrap carries its own state: alchemy's state store keys each resource by stack, stage, and resource name (@alchemy.run/cloudflare src/StateStore/Api.ts:75-79, :121-135; the stack name flows from Alchemy.Stack into that key, alchemy/src/Stack.ts:155-157, :216). Two repositories therefore never share a state key, and one repository's destroy cannot delete another's token or secrets.

The stack creates a Cloudflare account API token and two repository secrets, CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. The token holds:

  • Account: Workers Scripts Write, D1 Write, Workers Tail Read, and Secrets Store Write. Secrets Store Write is needed because every cloud stage keeps its state in a worker that is bound to the account's Secrets Store.
  • The SITE_DOMAIN zone only: Workers Routes Write, DNS Write, and Zone Read. Zone Read is needed because the custom-domain path looks the zone up by name.

After it runs:

  1. Confirm the secrets exist with gh secret list (names only).
  2. Revoke the previous hand-made Cloudflare token by name in the Cloudflare dashboard.
  3. Set the repository variable ALCHEMY_DEPLOY to true, and SITE_DOMAIN to the same hostname so production serves on it. Nothing deploys until ALCHEMY_DEPLOY is set.
  4. To rotate the token's value, at least every 90 days, run pnpm exec alchemy destroy --config stacks/github.ts --stage ci-credentials with the same environment, then run the deploy command again. The destroy deletes the token, the deploy mints a new one and rewrites both secrets, and deploys fail in between.

A new resource type needs its permission group added to apps/site/stacks/github.ts.


🚦 Verification Gates

All changes must satisfy local and continuous integration verification gates:

# Format code and markdown
pnpm format:check

# Typecheck workspace packages
pnpm typecheck

# Run linter across packages
pnpm lint

# Run unit and integration tests
pnpm test
# Run every local gate (a superset of what CI runs)
pnpm check:ci

❓ Frequently Asked Questions

Why does starter require Effect 4 instead of Effect 3?

Effect 4 brings the schema transformations and typed services the template's decisions and boundaries are written with. starter targets the future of Effect rather than supporting legacy patterns.

Why are there no configuration options or preset levels?

Every configuration toggle provides a route for AI agents to downgrade verification standards and reintroduce slop. Zero knobs guarantees that all packages created from this template adhere to identical architectural standards.

How does mutation testing work in this template?

Stryker introduces deliberate syntax and logic mutations into your code and runs your test suite against each mutant. If your tests still pass when code behavior changes, the mutant survives. Mutation is an on-demand script, pnpm --filter @endgame/site mutation (stryker run), never a CI job and never a gate in front of a deploy. The shared config (stryker.shared.ts) mutates only **/*.workflow.ts files in the package, runs the Vitest tests related to each mutant, and breaks the run below a 100% kill score.

How do I migrate an existing codebase to this architecture?

Follow the strangler pattern: pin the published observable behavior of a module, delete the legacy file completely, and rebuild it from a blank page using pure I/O sandwiches. Never patch around a flawed core.


🀝 Contributing

Development setup, workflows, and PR guidelines are documented in CONTRIBUTING.md.


πŸ“„ License

Licensed under the Apache-2.0 License.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages