diff --git a/.agent/skills/effect-pattern-discovery/SKILL.md b/.agent/skills/effect-pattern-discovery/SKILL.md new file mode 100644 index 00000000..3053f14b --- /dev/null +++ b/.agent/skills/effect-pattern-discovery/SKILL.md @@ -0,0 +1,195 @@ +--- +name: effect-pattern-discovery +description: > + Effect-TS patterns sourced from effect-smol reference implementation. + Trigger: When implementing Effect, Layer, Schema, Pipe, Context, or error handling patterns. +license: Apache-2.0 +metadata: + author: gentleman-programming + version: "1.0" +--- + +## When to Use + +- Implementing Effect-based services or layers +- Creating structured errors with Data.TaggedError or Schema.ErrorClass +- Building pipeable modules with dual functions +- Defining Context/Service patterns with Context.Tag +- Composing Layers with provide/merge + +## Critical Patterns + +### ❌ FORBIDDEN: try-catch in Effect.gen + +```typescript +// ❌ WRONG - Breaks Effect semantics +Effect.gen(function*() { + try { + return yield* someEffect + } catch (error) { + // Never reached! + } +}) + +// ✅ CORRECT - Use Effect.result or Effect.catchTag +Effect.gen(function*() { + const result = yield* Effect.result(someEffect) + if (result._tag === "Failure") { + return yield* Effect.fail(handleError(result.cause)) + } + return result.value +}) +``` + +### ❌ FORBIDDEN: Type Assertions + +```typescript +// ❌ FORBIDDEN - These break type safety +const value = something as any +const value = something as never + +// ✅ CORRECT - Use proper Effect constructors +const safeValue = Effect.try(() => JSON.parse(jsonString)) +``` + +### ✅ MANDATORY: return yield\* for Terminal Effects + +```typescript +Effect.gen(function*() { + if (invalidCondition) { + return yield* Effect.fail("Error") // ✅ correct + } + + if (shouldInterrupt) { + return yield* Effect.interrupt // ✅ correct + } + + return result // ✅ correct +}) +``` + +## Code Examples + +### Service Definition with Context.Tag + +```typescript +import { Context, Effect, Layer } from "effect" + +class DatabaseService extends Context.Tag("DatabaseService")< + DatabaseService, + { + readonly query: ( + sql: string, + ) => Effect.Effect + } +>() {} + +class UserService extends Context.Tag("UserService")< + UserService, + { + readonly getUser: (id: string) => Effect.Effect + } +>() {} + +// Layer implementation +const DatabaseServiceLive = Layer.succeed( + DatabaseService, + DatabaseService.of({ + query: (sql) => + Effect.tryPromise({ + try: () => db.execute(sql), + catch: (e) => new DatabaseError({ cause: e }), + }), + }), +) +``` + +### Structured Errors with Data.TaggedError + +```typescript +import { Data, Effect } from "effect" + +class ValidationError extends Data.TaggedError("ValidationError")<{ + field: string + message: string +}> {} + +class NetworkError extends Data.TaggedError("NetworkError")<{ + status: number + url: string + cause?: unknown +}> {} + +// Usage with catchTag +const program = operation().pipe( + Effect.catchTag("ValidationError", (e) => Console.log(`Invalid field: ${e.field}`)), +) +``` + +### Schema.ErrorClass for Serializable Errors + +```typescript +import { Schema } from "effect" + +class ApiError extends Schema.ErrorClass("@effectify/ApiError")({ + _tag: Schema.tag("ApiError"), + code: Schema.String, + message: Schema.String, +}) { + get message() { + return `[${this.code}] ${this.message}` + } +} +``` + +### Effect.fn vs Effect.fnUntraced + +| Function | Use Case | Tracing | +| ------------------------- | -------------------------- | ----------- | +| `Effect.fn("name")(fn)` | Public API, reusable | ✅ Yes | +| `Effect.fnUntraced(fn)` | Internal hot paths | ❌ No | +| `Effect.gen(function*())` | One-off inline composition | Uses parent | + +### Dual Function Pattern + +```typescript +import { dual } from "effect" + +export const map = dual< + (f: (a: A) => B) => (self: Module) => Module, + (self: Module, f: (a: A) => B) => Module +>(2, (self, f) => /* implementation */) +``` + +### Layer Composition + +```typescript +const AppLayer = UserServiceLive.pipe( + Layer.provide(DatabaseServiceLive), + Layer.launch, +) + +// Or with merge +const CombinedLayer = Layer.merge(UserServiceLive, DatabaseServiceLive) +``` + +## Commands + +```bash +# Scan for similar patterns in effect-smol +ls .effect-reference/packages/effect/src/ + +# Check internal implementations +cat .effect-reference/packages/effect/src/internal/effect.ts + +# Look at module exports +cat .effect-reference/packages/effect/src/index.ts +``` + +## Resources + +- **effect-smol source**: `.effect-reference/packages/effect/src/` +- **Pattern docs**: `.effect-reference/.patterns/` +- **Error handling**: `.effect-reference/.patterns/error-handling.md` +- **Module organization**: `.effect-reference/.patterns/module-organization.md` +- **Library development**: `.effect-reference/.patterns/effect-library-development.md` diff --git a/.engram/chunks/3775ed35.jsonl.gz b/.engram/chunks/3775ed35.jsonl.gz new file mode 100644 index 00000000..5db24380 Binary files /dev/null and b/.engram/chunks/3775ed35.jsonl.gz differ diff --git a/.engram/chunks/a266fb9e.jsonl.gz b/.engram/chunks/a266fb9e.jsonl.gz new file mode 100644 index 00000000..3db5d1a9 Binary files /dev/null and b/.engram/chunks/a266fb9e.jsonl.gz differ diff --git a/.engram/manifest.json b/.engram/manifest.json index 28c20aab..b54e7176 100644 --- a/.engram/manifest.json +++ b/.engram/manifest.json @@ -16,6 +16,22 @@ "sessions": 9, "memories": 20, "prompts": 0 + }, + { + "id": "3775ed35", + "created_by": "andres", + "created_at": "2026-03-21T07:05:04Z", + "sessions": 30, + "memories": 21, + "prompts": 0 + }, + { + "id": "a266fb9e", + "created_by": "andres", + "created_at": "2026-03-22T18:01:37Z", + "sessions": 29, + "memories": 20, + "prompts": 0 } ] } diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-alpha.yml similarity index 89% rename from .github/workflows/release-beta.yml rename to .github/workflows/release-alpha.yml index 0be41422..1718eaf9 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-alpha.yml @@ -1,4 +1,4 @@ -name: 🚀 Release Beta +name: 🚀 Release Alpha on: push: @@ -13,8 +13,8 @@ env: DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/effectify" jobs: - release-beta: - name: 🚀 Release Beta + release-alpha: + name: 🚀 Release Alpha runs-on: ubuntu-latest permissions: contents: write # Needed for git operations @@ -120,20 +120,20 @@ jobs: pnpm nx run-many -t test --projects=${{ steps.affected.outputs.projects }} --parallel=3 --passWithNoTests || true # Release: version bump + publish (skipped if no affected projects) - - name: 🚀 Version & Publish Beta + - name: 🚀 Version & Publish Alpha if: ${{ steps.affected.outputs.has_projects == 'true' }} run: | PROJECTS="${{ steps.affected.outputs.projects }}" echo "Releasing: $PROJECTS" - # Version bump with preid=beta → creates versions like 0.0.5-beta.0 - pnpm nx release version --projects=$PROJECTS --preid=beta + # Version bump with preid=alpha → creates versions like 0.0.5-alpha.0 + pnpm nx release version --projects=$PROJECTS --preid=alpha # Rebuild with new versions (version bump updates package.json) pnpm nx run-many -t build --projects=$PROJECTS --parallel=3 - # Publish to npm with beta tag - pnpm nx release publish --projects=$PROJECTS --tag=beta + # Publish to npm with alpha tag + pnpm nx release publish --projects=$PROJECTS --tag=alpha # Push git tags and version commits git push origin HEAD --follow-tags @@ -146,7 +146,7 @@ jobs: - name: 📊 Release Summary if: always() run: | - echo "## 🚀 Beta Release Summary" >> $GITHUB_STEP_SUMMARY + echo "## 🚀 Alpha Release Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if [ "${{ steps.affected.outputs.has_projects }}" = "true" ]; then echo "✅ **Release Completed**" >> $GITHUB_STEP_SUMMARY @@ -157,11 +157,11 @@ jobs: echo "|------|--------|" >> $GITHUB_STEP_SUMMARY echo "| Detect Affected | ✅ |" >> $GITHUB_STEP_SUMMARY echo "| Build | ✅ |" >> $GITHUB_STEP_SUMMARY - echo "| Version Bump | ✅ With preid=beta |" >> $GITHUB_STEP_SUMMARY - echo "| NPM Tag | beta |" >> $GITHUB_STEP_SUMMARY + echo "| Version Bump | ✅ With preid=alpha |" >> $GITHUB_STEP_SUMMARY + echo "| NPM Tag | alpha |" >> $GITHUB_STEP_SUMMARY echo "| Provenance | ✅ Enabled |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "📦 Packages published with \"@beta\" dist-tag and semver prerelease versions (x.x.x-beta.x)." >> $GITHUB_STEP_SUMMARY + echo "📦 Packages published with \"@alpha\" dist-tag and semver prerelease versions (x.x.x-alpha.x)." >> $GITHUB_STEP_SUMMARY else echo "⏭️ **Skipped - No affected projects**" >> $GITHUB_STEP_SUMMARY fi diff --git a/.gitignore b/.gitignore index d78baa90..9bc25cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ node_modules # misc /.sass-cache /connect.lock -/coverage +coverage /libpeerconnection.log npm-debug.log yarn-error.log diff --git a/.nxignore b/.nxignore new file mode 100644 index 00000000..4f0c27bc --- /dev/null +++ b/.nxignore @@ -0,0 +1 @@ +.effect-reference/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 2f335e2b..a849230f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -58,10 +58,12 @@ "files.readonlyInclude": { "**/routeTree.gen.ts": true }, - "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.defaultFormatter": "dprint.dprint", "[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": { "editor.defaultFormatter": "dprint.dprint" }, "editor.formatOnPaste": true, - "emmet.showExpandedAbbreviation": "never" + "emmet.showExpandedAbbreviation": "never", + "typescript.experimental.useTsgo": true, + "typescript.native-preview.tsdk": "node_modules/@typescript/native-preview" } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index c42abcc6..e25334bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,3 +20,13 @@ If the user wants help with fixing an error in their CI pipeline, use the follow - Make sure that the problem is fixed by running the task that you passed into the `nx_cloud_fix_cipe_failure` tool + +## Effect-TS Pattern Discovery + +This project uses effect-smol as the canonical source for Effect patterns. + +| Skill | Description | Location | +| -------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------- | +| `effect-pattern-discovery` | Effect-TS patterns from effect-smol | [.agent/skills/effect-pattern-discovery](.agent/skills/effect-pattern-discovery/SKILL.md) | + +**Reference Directory**: `.effect-reference/` contains the effect-smol source code mounted as a git worktree. diff --git a/README.md b/README.md index ea80f56f..827ae3df 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Effectify -[![Beta Release](https://img.shields.io/badge/beta-v4%20beta-blue)](https://www.npmjs.com/search?q=%40effectify) +[![Alpha Release](https://img.shields.io/badge/alpha-v4%20alpha-blue)](https://www.npmjs.com/search?q=%40effectify) [![Documentation](https://img.shields.io/badge/docs-effectify.dev-00C853)](https://devx-op.github.io/effectify/) Monorepo of utilities for integrating [Effect](https://effect.website/) with different frameworks and libraries. -> **🚀 Effect v4 Beta Support**: We are currently migrating packages to support Effect v4 beta. Beta versions are available on npm with the `@beta` tag. +> **🚀 Effect v4 Alpha Support**: We are currently migrating packages to support Effect v4 beta. Alpha versions are available on npm with the `@alpha` tag. ## Packages @@ -20,43 +20,43 @@ Monorepo of utilities for integrating [Effect](https://effect.website/) with dif | [@effectify/prisma](https://www.npmjs.com/package/@effectify/prisma) | [![npm version](https://img.shields.io/npm/v/@effectify/prisma.svg)](https://www.npmjs.com/package/@effectify/prisma) | [Docs](./packages/prisma/README.md) | Prisma generator and runtime utilities for Effect | | [@effectify/solid-effect-atom](https://www.npmjs.com/package/@effectify/solid-effect-atom) | [![npm version](https://img.shields.io/npm/v/@effectify/solid-effect-atom.svg)](https://www.npmjs.com/package/@effectify/solid-effect-atom) | [Docs](./packages/solid/effect-atom/README.md) | Reactive toolkit for Effect with SolidJS | -## Beta Installation (Effect v4) +## Alpha Installation (Effect v4) -We are actively migrating packages to support Effect v4 beta. You can install beta versions using the `@beta` npm tag: +We are actively migrating packages to support Effect v4 beta. You can install alpha versions using the `@alpha` npm tag: ```bash # npm -npm install @effectify/react-query@beta -npm install @effectify/solid-query@beta -npm install @effectify/react-router@beta -npm install @effectify/node-better-auth@beta -npm install @effectify/react-remix@beta -npm install @effectify/prisma@beta +npm install @effectify/react-query@alpha +npm install @effectify/solid-query@alpha +npm install @effectify/react-router@alpha +npm install @effectify/node-better-auth@alpha +npm install @effectify/react-remix@alpha +npm install @effectify/prisma@alpha # pnpm -pnpm add @effectify/react-query@beta +pnpm add @effectify/react-query@alpha # yarn -yarn add @effectify/react-query@beta +yarn add @effectify/react-query@alpha ``` ### Migration Status -| Package | v4 Beta Status | Stable Version | -| --------------------------- | -------------- | -------------- | -| @effectify/react-query | 🚧 In Progress | ✅ v3 | -| @effectify/solid-query | 🚧 In Progress | ✅ v3 | -| @effectify/react-router | 🚧 In Progress | ✅ v3 | -| @effectify/node-better-auth | 🚧 In Progress | ✅ v3 | -| @effectify/react-remix | 🚧 In Progress | ✅ v3 | -| @effectify/prisma | 🚧 In Progress | ✅ v3 | +| Package | v4 Alpha Status | Stable Version | +| --------------------------- | --------------- | -------------- | +| @effectify/react-query | 🚧 In Progress | ✅ v3 | +| @effectify/solid-query | 🚧 In Progress | ✅ v3 | +| @effectify/react-router | 🚧 In Progress | ✅ v3 | +| @effectify/node-better-auth | 🚧 In Progress | ✅ v3 | +| @effectify/react-remix | 🚧 In Progress | ✅ v3 | +| @effectify/prisma | 🚧 In Progress | ✅ v3 | **Legend**: ✅ Available | 🚧 Migrating | ⏳ Pending ### Effect v3 vs v4 - **Stable releases** (v3.x) continue to work with Effect v3.19.x -- **Beta releases** (v4.x) require Effect v4 beta +- **Alpha releases** (v4.x) require Effect v4 beta - Both versions maintain the same API where possible For migration details, see the [Effect v4 Migration Guide](https://effect.website/docs/migration/v4). diff --git a/apps/docs/src/content/docs/universal/index.mdx b/apps/docs/src/content/docs/universal/index.mdx index c54895e6..57f9d3f2 100644 --- a/apps/docs/src/content/docs/universal/index.mdx +++ b/apps/docs/src/content/docs/universal/index.mdx @@ -217,7 +217,7 @@ export const MessageSchema = Schema.struct({ userId: Schema.string, roomId: Schema.string, timestamp: Schema.Date, - type: Schema.literal('text', 'image', 'file') + type: Schema.literals(['text', 'image', 'file']) }) // Use for validation diff --git a/apps/node-auth-example/project.json b/apps/node-auth-example/project.json index 41bab72d..6a9a52f3 100644 --- a/apps/node-auth-example/project.json +++ b/apps/node-auth-example/project.json @@ -5,35 +5,12 @@ "projectType": "application", "targets": { "build": { - "executor": "@nx/js:tsc", - "outputs": ["{options.outputPath}"], - "defaultConfiguration": "production", + "executor": "nx:run-commands", "options": { - "outputPath": "apps/node-auth-example/dist", - "main": "apps/node-auth-example/src/main.ts", - "tsConfig": "apps/node-auth-example/tsconfig.app.json", - "format": ["esm"], - "generatePackageJson": false, - "updateBuildableProjectDepsInPackageJson": false, - "additionalEntryPoints": [], - "assets": [ - "apps/node-auth-example/src/assets", - { - "input": "node_modules/better-sqlite3/build/Release", - "glob": "better_sqlite3.node", - "output": "./" - }, - { - "input": "apps/node-auth-example", - "glob": "sqlite.db", - "output": "./" - } - ] + "command": "tsc --build tsconfig.app.json", + "cwd": "apps/node-auth-example" }, - "configurations": { - "development": {}, - "production": {} - } + "outputs": ["{projectRoot}/dist"] }, "serve": { "executor": "@nx/js:node", diff --git a/apps/node-auth-example/src/http.ts b/apps/node-auth-example/src/http.ts index 4a20d123..23b4eecc 100644 --- a/apps/node-auth-example/src/http.ts +++ b/apps/node-auth-example/src/http.ts @@ -8,5 +8,5 @@ import { pipe } from "effect/Function" export const Live = pipe( HttpServer.serve(toEffectHandler(Auth.auth)), - Layer.provide(NodeHttpServer.layer(() => createServer(), { port: 3001 })), + Layer.provide(NodeHttpServer.layer(createServer, { port: 3001 })), ) diff --git a/apps/node-auth-example/tsconfig.app.json b/apps/node-auth-example/tsconfig.app.json index c025d1e8..8ae8aaa0 100644 --- a/apps/node-auth-example/tsconfig.app.json +++ b/apps/node-auth-example/tsconfig.app.json @@ -10,9 +10,8 @@ "allowSyntheticDefaultImports": true, "esModuleInterop": true, "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo", - "baseUrl": "./" + "tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo" }, - "include": ["src/**/*.ts"], + "include": ["./src/**/*.ts"], "exclude": [] } diff --git a/apps/react-remix-example/tsconfig.json b/apps/react-remix-example/tsconfig.json index 9d87dd37..42332286 100644 --- a/apps/react-remix-example/tsconfig.json +++ b/apps/react-remix-example/tsconfig.json @@ -21,7 +21,6 @@ "allowJs": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "baseUrl": ".", "paths": { "~/*": ["./app/*"] }, diff --git a/apps/react-router-example/app/lib/runtime.server.ts b/apps/react-router-example/app/lib/runtime.server.ts index cc15b4cb..63f04559 100644 --- a/apps/react-router-example/app/lib/runtime.server.ts +++ b/apps/react-router-example/app/lib/runtime.server.ts @@ -3,9 +3,22 @@ import { Runtime } from "@effectify/react-router" import { AuthService } from "@effectify/node-better-auth" import * as Layer from "effect/Layer" import { authOptions } from "./better-auth-options.server.js" +import { Prisma } from "./../../prisma/generated/effect/index.js" +import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3" const Authlayer = AuthService.AuthServiceContext.layer(authOptions) -const AppLayer = Layer.mergeAll(Authlayer) +const adapter = new PrismaBetterSqlite3({ + url: process.env.DATABASE_URL ?? "file:./dev.db", +}) + +const AppLayer = Layer.mergeAll( + Authlayer, + Prisma.layer({ + // Prisma Client options + adapter, + log: ["query", "info", "warn", "error"], + }), +) export const { withLoaderEffect, withActionEffect } = Runtime.make(AppLayer) diff --git a/apps/react-router-example/app/routes/todo-app.tsx b/apps/react-router-example/app/routes/todo-app.tsx index b955964d..23e3505d 100644 --- a/apps/react-router-example/app/routes/todo-app.tsx +++ b/apps/react-router-example/app/routes/todo-app.tsx @@ -5,22 +5,18 @@ import { withActionEffect, withLoaderEffect } from "../lib/runtime.server.js" import { randomUUID } from "node:crypto" import { Form, useActionData, useSubmit } from "react-router" import { useState } from "react" -import { withBetterAuthGuard } from "@effectify/react-router-better-auth" - -let todos = [ - { - id: "1", - title: "Todo 1", - content: "Content 1", - status: "PENDING", - }, -] +import { withBetterAuthGuard, withBetterAuthGuardAction } from "@effectify/react-router-better-auth" +import * as PrismaRepository from "./../../prisma/generated/effect/prisma-repository.js" +import { TodoId, TodoModel } from "./../../prisma/generated/effect/index.js" export const loader = Effect.gen(function*() { + const TodoRepo = yield* PrismaRepository.make(TodoModel, { + modelName: "todo", + spanPrefix: "Todo", + }) + const todos = yield* TodoRepo.findMany() return yield* httpSuccess({ todos: todos }) -}) - .pipe(withBetterAuthGuard.with({ redirectOnFail: "/login" })) - .pipe(withLoaderEffect) +}).pipe(withBetterAuthGuard.with({ redirectOnFail: "/login" }), withLoaderEffect) export const action = Effect.gen(function*() { const { request } = yield* ActionArgsContext @@ -30,11 +26,16 @@ export const action = Effect.gen(function*() { const title = String(formData.get("title") ?? "") const content = String(formData.get("content") ?? "") + const TodoRepo = yield* PrismaRepository.make(TodoModel, { + modelName: "todo", + spanPrefix: "Todo", + }) + if (intent === "delete") { if (!id) { return yield* httpFailure("Missing id") } - todos = todos.filter((todo) => todo.id !== id) + yield* TodoRepo.delete({ where: { id } }) return yield* httpRedirect("/todo-app") } @@ -46,7 +47,7 @@ export const action = Effect.gen(function*() { return yield* httpFailure("Title is required") } - todos = todos.map((todo) => todo.id === id ? { ...todo, title, content } : todo) + yield* TodoRepo.update({ where: { id }, data: { title, content } }) return yield* httpRedirect("/todo-app") } @@ -55,8 +56,8 @@ export const action = Effect.gen(function*() { return yield* httpFailure("Missing id") } const statusStr = String(formData.get("status") ?? "") - const status = statusStr === "COMPLETED" ? "COMPLETED" : "PENDING" - todos = todos.map((todo) => todo.id === id ? { ...todo, status } : todo) + const status = statusStr === "COMPLETED" ? "COMPLETED" : ("PENDING" as const) + yield* TodoRepo.update({ where: { id }, data: { status } }) return yield* httpRedirect("/todo-app") } @@ -64,21 +65,20 @@ export const action = Effect.gen(function*() { return yield* httpFailure("Title is required") } - todos.push({ - id: randomUUID(), - title, - content, - status: "PENDING", + yield* TodoRepo.create({ + data: { + id: TodoId.makeUnsafe(randomUUID()), + title, + content, + status: "PENDING", + authorId: 1, + published: false, + }, }) - return yield* httpRedirect("/todo-app") -}) - .pipe(withBetterAuthGuard.with({ redirectOnFail: "/login" })) - .pipe(withActionEffect) +}).pipe(withBetterAuthGuardAction.with({ redirectOnFail: "/login" }), withActionEffect) -export default function TodoApp({ - loaderData, -}: Route.ComponentProps) { +export default function TodoApp({ loaderData }: Route.ComponentProps) { const actionData = useActionData() const submit = useSubmit() const [editingId, setEditingId] = useState(null) @@ -91,14 +91,31 @@ export default function TodoApp({
- + -