Instruction file imported from FaithBase-AI/openfaith (
.cursor/rules/rules.mdc). Copyright stays with the author.
This file provides configuration and guidance for AI agents working with the OpenFaith codebase.
Project Overview
OpenFaith is a local-first church management system built with Effect-TS that provides a unified interface for managing church data across multiple Church Management Systems (ChMS) like Planning Center Online and Church Community Builder.
Development Commands
Essential Commands
bun run dev- Start all development servers (frontend + backend + infrastructure)bun run build- Build all packages for productionbun run typecheck- Run TypeScript checks across all packagesbun run check- Run comprehensive quality checks (format, lint, typecheck, test)bun run test- Run tests across all packages
Development Server Policy
NEVER automatically start the dev server (bun run dev) or any long-running processes. Always ask the user to start these services themselves. This prevents:
- Interrupting existing development sessions
- Port conflicts and resource contention
- Unexpected process termination
- Loss of user control over their development environment
Instead of starting servers:
- Ask the user to run the appropriate command
- Provide clear instructions on what to test
- Wait for user feedback on results
Code Quality
bun run format- Check code formatting with Biomebun run format:fix- Fix code formatting issues automatically. Run this after all changes before handing back to user
Database & Infrastructure
bun run db:generate- Generate database types from schemabun run db:migrate- Run database migrationsbun run infra- Start infrastructure services (PostgreSQL, Redis, OpenTelemetry)
Technology Stack
Core Philosophy: Effect-TS First
This project is built around the Effect-TS ecosystem across the entire stack, providing functional programming patterns with excellent error handling, async operations, and dependency injection.
Frontend:
- React with Effect-TS ecosystem
- Tanstack Router for routing
- Vite for build tooling
- TailwindCSS for styling
- Effect-RX for reactive state management
Backend:
- Effect-TS ecosystem with Effect HTTP and RPC
- PostgreSQL with Drizzle ORM (Effect-integrated)
- Better Auth for authentication
- @effect/cluster and @effect/workflow for durable workflows
Infrastructure:
- Turborepo monorepo with Bun package manager
- Rocicorp Zero for client-side sync
- Custom adapter pattern for ChMS integrations
Architecture
Monorepo Structure
apps/openfaith/ # Main React application
packages/ # Shared libraries
├── db/ # Database schema and migrations
├── schema/ # Effect Schema definitions (CDM)
├── ui/ # Shared UI components
├── auth/ # Authentication utilities
├── zero/ # Client-side sync configuration
└── shared/ # Common utilities
backend/ # Server-side services
├── server/ # Main API server
├── workers/ # Effect workflows (@effect/cluster + @effect/workflow)
└── email/ # Email service
adapters/ # ChMS integration adapters
├── pco/ # Planning Center Online
├── ccb/ # Church Community Builder
└── adapter-core/ # Shared adapter utilities
infra/ # Infrastructure services
Data Architecture
- Canonical Data Model (CDM): Core entities (Person, Group, Folder, Edge, ExternalLink)
- Effect Schema: Type-safe data modeling with runtime validation
- Adapter Pattern: ChMS integrations with Effect-based error handling
- Sync Engine: Bi-directional data synchronization using Effect workflows (@effect/cluster + @effect/workflow)
- Edge-based Relationships: Flexible entity connections
Development Patterns
Effect-TS Conventions (Frontend & Backend)
Core Patterns:
- NEVER use async/await - always use Effect: Use
Effect.genfor all asynchronous operations - NO Promise-based code: Convert all Promise-based APIs to Effect using
Effect.promiseorEffect.tryPromise - NO async functions: All functions should return
Effect<A, E, R>instead ofPromise<A> - Use
Effect.fnfor Effect functions with parameters: When creating Effect functions that take parameters, wrap them withEffect.fnfor better tracing and debugging - Prefer
pipefor function composition over method chaining - Use Effect's service pattern for dependency injection
- Define tagged errors using
Schema.TaggedError(notData.TaggedError) - Use Effect Schema for all data validation and transformation
- Leverage Effect's Layer system for service composition
Context Tag Naming:
Format: @<package-name>/<path>/<TagName>
Examples:
@openfaith/adapter-core/EntityManifest@openfaith/server/SessionContext
Frontend Specific:
- Use Effect-RX for React state management
- Structure data fetching with Effect patterns
- Use
useRxQueryanduseRxMutationhooks - All client operations should use Effect for error handling
- React hooks with Option types: When using React hooks (useMemo, useCallback, etc.) with Option types, use
useStableMemofrom@openfaith/ui/shared/hooks/memowith proper Equivalence- Required for Option types:
useStableMemoprevents unnecessary re-renders when Option values haven't actually changed - Import:
import { useStableMemo } from '@openfaith/ui/shared/hooks/memo' - Pattern:
useStableMemo(() => computation, [deps], Equivalence.equivalence) - Example:
const entityConfigOpt = useStableMemo(() => findEntity(), [group, entity], Option.getEquivalence(Equivalence.strict)) - Why required: Option.some(value) !== Option.some(value) in JavaScript, causing unnecessary re-renders with regular useMemo
- Required for Option types:
Backend Specific:
- Wrap all database operations in Effect
- Use Effect's resource management for connections
- Structure services using Effect's service pattern
- Use Effect HTTP for API endpoints
Code Style
- Biome for formatting and linting (primary)
- Single quotes (
') for strings, not double quotes (") - No semicolons (
;) at the end of lines - Trailing commas required
- 2-space indentation
- Option variable naming: Variables that contain
Optiontypes MUST end withOptsuffix- Prefer:
const entityConfigOpt = Option.some(config) - Avoid:
const entityConfig = Option.some(config) - Prefer:
const userDataOpt = pipe(users, Array.head) - Avoid:
const userData = pipe(users, Array.head) - This makes it immediately clear that the variable is an Option and requires Option.match or similar handling
- Prefer:
- Use absolute imports based on package tsconfig, not relative imports
- Always use absolute imports that match the package structure
- Prefer:
@openfaith/server/live/httpAuthMiddlewareLive - Avoid:
../live/httpAuthMiddlewareLive - Prefer:
@openfaith/workers/helpers/ofLookup - Avoid:
./ofLookup - Use the package name prefix (e.g.,
@openfaith/) followed by the path from the package root - Check the package's
tsconfig.jsonand roottsconfig.jsonfor path mappings - This ensures imports work correctly with the monorepo tsconfig setup and IDE support
- Avoid destructuring in function parameters when it reduces readability
- Prefer:
Array.groupBy((item) => item.entityName) - Avoid:
Array.groupBy(({ entityName }) => entityName) - Prefer:
Effect.forEach((entityWorkflow) => entityWorkflow.entityName) - Avoid:
Effect.forEach(({ entityName }) => entityName)
- Prefer:
- Use descriptive variable names, avoid abbreviations
- Prefer:
(error) => error.message - Avoid:
(err) => err.message
- Prefer:
- Avoid direct array access, use Effect's safe array utilities
- Prefer:
Array.head(mutation.args)withOption.match - Avoid:
mutation.args[0] - Prefer:
Array.get(items, index)withOption.match - Avoid:
items[index]
- Prefer:
- NEVER use single-line if statements
- ALWAYS use block statements with curly braces for if statements
- Prefer: Multi-line format for better readability and consistency
- Examples:
- Avoid:
if (!entity.navConfig.enabled) return Option.none() - Prefer:
if (!entity.navConfig.enabled) { return Option.none(); } - Avoid:
if (condition) doSomething() - Prefer:
if (condition) { doSomething(); }
- Avoid:
- This applies to all control flow statements: if, else, for, while, etc.
- Improves code readability, makes debugging easier, and prevents errors when adding additional statements
CRITICAL RULE: Effect Array Utilities - NEVER USE NATIVE ARRAY METHODS
⚠️ ABSOLUTELY FORBIDDEN ⚠️ - Native Array Methods:
- NEVER use
.map(),.filter(),.forEach(),.find(),.some(),.every(),.reduce()on arrays - NEVER use
Array.from(),Array.isArray(), or any native Array static methods - NEVER use
for...of,for...in, or traditionalforloops for array iteration
✅ REQUIRED PATTERN - Always Use pipe(array, Array.method(...)):
// ❌ FORBIDDEN - Native array methods
items.map((item) => item.name);
items.filter((item) => item.active);
items.forEach((item) => console.log(item));
items.find((item) => item.id === targetId);
Array.from(iterable);
// ✅ REQUIRED - Effect Array utilities with pipe
pipe(
items,
Array.map((item) => item.name)
);
pipe(
items,
Array.filter((item) => item.active)
);
pipe(
items,
Array.forEach((item) => Effect.log(item))
);
pipe(
items,
Array.findFirst((item) => item.id === targetId)
);
pipe(iterable, Array.fromIterable);
Use Effect's Array utilities for ALL array operations - no exceptions:
- Business logic transformations: Always
pipe(array, Array.method(...)) - JSX rendering: Always
pipe(array, Array.map(...))in JSX - Data processing: All API responses, database results use the same pattern
- Any array manipulation: Grouping, sorting, filtering - all use Effect's Array utilities
- Type definitions and interfaces: When working with ASTs or object properties that are arrays
Key Effect Array Methods to Use:
Array.map()- Transform each elementArray.filter()- Filter elements by predicateArray.forEach()- Side effects for each elementArray.findFirst()- Find first matching element (returnsOption)Array.findLast()- Find last matching element (returnsOption)Array.some()- Test if any element matchesArray.every()- Test if all elements matchArray.reduce()- Reduce array to single valueArray.groupBy()- Group elements by keyArray.partition()- Split array into two based on predicateArray.fromIterable()- Convert iterable to arrayArray.head()- Get first element (returnsOption)Array.tail()- Get all but first element (returnsOption)Array.get()- Safe array access by index (returnsOption)
This ensures consistency across ALL array operations in the codebase - no exceptions allowed.
CRITICAL RULE: Effect String Utilities - NEVER USE NATIVE STRING METHODS
⚠️ ABSOLUTELY FORBIDDEN ⚠️ - Native String Methods:
- NEVER use
.charAt(),.slice(),.substring(),.indexOf(),.includes(),.startsWith(),.endsWith()on strings - NEVER use
.toUpperCase(),.toLowerCase(),.trim(),.split(),.replace()on strings - NEVER use
.match(),.search(),.padStart(),.padEnd()on strings - NEVER use template literal string manipulation without Effect String utilities
✅ REQUIRED PATTERN - Always Use Effect's String utilities:
// ❌ FORBIDDEN - Native string methods
const result = str.charAt(0).toUpperCase() + str.slice(1);
const isValid = str.endsWith("s");
const parts = str.split(" ");
const trimmed = str.trim();
const replaced = str.replace(/old/g, "new");
// ✅ REQUIRED - Effect String utilities
const result = pipe(
str,
String.charAt(0),
String.toUpperCase,
(firstChar) => `${firstChar}${pipe(str, String.slice(1))}`
);
const isValid = pipe(str, String.endsWith("s"));
const parts = pipe(str, String.split(" "));
const trimmed = pipe(str, String.trim);
const replaced = pipe(str, String.replace(/old/g, "new"));
Key Effect String Methods to Use:
String.charAt()- Get character at indexString.slice()- Extract substringString.substring()- Extract substring (alternative)String.indexOf()- Find index of substringString.includes()- Check if string contains substringString.startsWith()- Check if string starts with substringString.endsWith()- Check if string ends with substringString.toUpperCase()- Convert to uppercaseString.toLowerCase()- Convert to lowercaseString.capitalize()- Capitalize first letterString.trim()- Remove whitespaceString.split()- Split string into arrayString.replace()- Replace substringString.match()- Match against regexString.isEmpty()- Check if string is emptyString.isNonEmpty()- Check if string is not empty
String manipulation must ALWAYS use Effect's String utilities with pipe - no exceptions allowed.
- Use Effect's Record utilities instead of native Object methods
- Prefer:
pipe(obj, Record.keys)instead ofObject.keys(obj) - Prefer:
pipe(obj, Record.values)instead ofObject.values(obj) - Prefer:
pipe(obj, Record.entries)instead ofObject.entries(obj) - Prefer:
pipe(obj, Record.map((value) => transform(value)))instead of manual object iteration - Use Effect's Record utilities for all object manipulation and transformation
- Prefer:
- Use Effect's collection utilities instead of native JavaScript collections
- HashMap instead of Map: Prefer
HashMap.empty(),HashMap.set(),HashMap.get(),HashMap.fromIterable()- Prefer:
HashMap.empty<string, number>()instead ofnew Map<string, number>() - Prefer:
pipe(hashMap, HashMap.set(key, value))instead ofmap.set(key, value) - Prefer:
pipe(hashMap, HashMap.get(key))instead ofmap.get(key)(returnsOption<V>) - Prefer:
HashMap.fromIterable(pairs)instead ofnew Map(pairs)
- Prefer:
- HashSet instead of Set: Prefer
HashSet.empty(),HashSet.add(),HashSet.has(),HashSet.fromIterable()- Import:
import { HashSet } from "effect" - Prefer:
HashSet.empty<string>()instead ofnew Set<string>() - Prefer:
pipe(hashSet, HashSet.add(value))instead ofset.add(value) - Prefer:
pipe(hashSet, HashSet.has(value))instead ofset.has(value) - Prefer:
HashSet.fromIterable(values)instead ofnew Set(values)
- Import:
- For mutable state: Use
Ref.make()with immutable collections for Effect-based state management- Pattern:
const cacheRef = Ref.make(HashMap.empty<K, V>()) - Update:
yield* Ref.update(cacheRef, (cache) => pipe(cache, HashMap.set(key, value)))
- Pattern:
- HashMap instead of Map: Prefer
- Use no-op utilities from
@openfaith/sharedinstead of inline functions- Prefer:
nullOpinstead of() => null - Prefer:
noOpinstead of() => {} - Prefer:
nullOpEinstead of() => Effect.succeed(null) - NEVER use async no-ops: Use
nullOpEinstead ofasync () => nullorasync () => {}
- Prefer:
- Use string utilities from
@openfaith/sharedfor text transformationspluralize(word: string): Converts singular words to plural (handles irregular plurals like "person" → "people")singularize(word: string): Converts plural words to singular (handles irregular singulars like "people" → "person")mkEntityName: Converts table name to entity name (people → Person, addresses → Address)mkTableName: Converts entity name to table name (Person → people, Address → addresses)mkZeroTableName: Converts entity name to Zero schema table name (Person → people, PhoneNumber → phoneNumbers)mkEntityType: Converts table name to entity type for IDs (people → person, phone_numbers → phonenumber)mkUrlParamName: Converts entity name to URL parameter name (Person → personId, PhoneNumber → phoneNumberId)- Examples:
- Prefer:
singularize("Groups")→ "Group" instead of manual string manipulation - Prefer:
pluralize("Person")→ "People" instead of adding "s" - Prefer:
mkEntityName("phone_numbers")→ "PhoneNumber" instead of manual case conversion - These utilities handle complex English pluralization rules and irregular cases automatically
- Prefer:
- React import patterns
- NEVER import React as default:
import React from 'react' - ALWAYS use named imports:
import { useState, useEffect } from 'react' - For JSX.Element types:
import type { ReactNode } from 'react' - For component types:
import type { ComponentType, FC } from 'react' - Examples:
- Prefer:
import { createElement } from 'react' - Avoid:
import React from 'react'; React.createElement(...) - Prefer:
import { useState, useCallback } from 'react' - Avoid:
import React, { useState, useCallback } from 'react' - Prefer:
import type { FC } from 'react'for component typing - Avoid:
import React, { FC } from 'react'
- Prefer:
- NEVER import React as default:
- React component props patterns
- NEVER destructure props in function parameters
- ALWAYS use props object with destructuring inside the component
- Use
FCtype when possible for better type inference and consistency - Patterns:
- With FC:
const Button: FC<ButtonProps> = (props) => { const { children, onClick, disabled = false } = props; ... } - With generics:
const MyComponent = <T,>(props: MyComponentProps<T>) => { const { data, onSelect = nullOp } = props; ... }
- With FC:
- Examples:
- Prefer:
const Button: FC<ButtonProps> = (props) => { const { children, onClick, disabled = false } = props; ... } - Avoid:
const Button = ({ children, onClick, disabled = false }: ButtonProps) => { ... } - Prefer:
const MyComponent = <T,>(props: MyComponentProps<T>) => { const { data, onSelect = nullOp } = props; ... } - Avoid:
const MyComponent = <T,>({ data, onSelect = nullOp }: MyComponentProps<T>) => { ... }
- Prefer:
- When to use each:
- Use
FC<Props>: For non-generic components (most common case) - Use generic function: For components that need generic type parameters
- Use
- This pattern improves readability and makes it easier to access the full props object when needed
- NO async/await in application code - use Effect instead
- Avoid:
async function loadData() { ... } - Prefer:
const loadData = Effect.gen(function* () { ... }) - Avoid:
await fetch(url)in application code - Prefer:
yield* Effect.tryPromise(() => fetch(url)) - Avoid:
Promise.all([a, b, c]) - Prefer:
Effect.all([a, b, c]) - Exception: async/await is allowed ONLY inside
Effect.promiseandEffect.tryPromisefunctions:- Allowed:
Effect.tryPromise(async () => { const response = await fetch(url); return response.json(); }) - Allowed:
Effect.promise(() => someAsyncFunction())
- Allowed:
- Avoid:
- NEVER use try/catch blocks anywhere in the codebase
- COMPLETELY FORBIDDEN:
try { ... } catch (e) { ... }blocks are anti-patterns in Effect-TS - Why forbidden: try/catch breaks Effect's error channel and prevents proper error composition
- Instead of try/catch: Use Effect's error composition with
orElse,catchAll,catchTag,catchSome - For unsafe operations: Use
Effect.tryPromise,Effect.try, orEffect.syncwith proper error handling - For validation: Use Effect Schema parsing which returns
ParseResult.ParseErrorin the error channel - For fallbacks: Use
Effect.orElse(() => fallbackEffect)instead of try/catch - Example anti-pattern:
// ❌ NEVER DO THIS - Breaks Effect's error handling const badFunction = () => { try { return someRiskyOperation(); } catch (error) { return null; } }; - Correct Effect pattern:
// ✅ Correct - Use Effect's error channel const goodFunction = Effect.gen(function* () { return yield* pipe( Effect.try(() => someRiskyOperation()), Effect.orElse(() => Effect.succeed(null)) ); });
- COMPLETELY FORBIDDEN:
- Use
Effect.fnfor parameterized Effect functions- Always use
Effect.fnwhen creating Effect functions that accept parameters - Provides better tracing, debugging, and observability
- Pattern:
const myFunction = Effect.fn('functionName')(function* (param1, param2) { ... }) - Examples:
- Prefer:
const loadIcon = Effect.fn('loadIcon')(function* (iconName: string) { ... }) - Avoid:
const loadIcon = (iconName: string) => Effect.gen(function* () { ... }) - Prefer:
const processEntity = Effect.fn('processEntity')(function* (entity: Entity, config: Config) { ... }) - Avoid:
const processEntity = (entity: Entity, config: Config) => Effect.gen(function* () { ... })
- Prefer:
- Use descriptive function names in the first parameter for better tracing
- Include
yield* Effect.annotateCurrentSpan()calls to add parameter values to traces
- Always use
CRITICAL RULE: Type Safety - NEVER USE any OR TYPE CASTING
⚠️ ABSOLUTELY FORBIDDEN ⚠️ - Type Safety Violations:
- NEVER use
anytype: Always use proper types, even if it requires more work - NEVER use type casting with
as: Use proper type guards, schema validation, or Effect utilities - NEVER use
unknownwithout proper validation: Always validate unknown types with Effect Schema - NEVER use
@ts-ignoreor@ts-expect-error: Fix the underlying type issue instead
✅ REQUIRED PATTERNS - Type-Safe Alternatives:
// ❌ FORBIDDEN - any types and casting
const data: any = response.data;
const user = data as User;
const items = response.items as Array<Item>;
// ✅ REQUIRED - Proper type validation
const UserSchema = Schema.Struct({
id: Schema.String,
name: Schema.String,
email: Schema.String,
});
const parseUser = (data: unknown) => Schema.decodeUnknown(UserSchema)(data);
// ✅ REQUIRED - Effect Schema for runtime validation
const processResponse = Effect.gen(function* () {
const response = yield* fetchData();
const user = yield* parseUser(response.data);
return user;
});
Type Safety Best Practices:
- Use Effect Schema for all data validation: Runtime type checking with compile-time types
- Define proper interfaces: Create specific types for all data structures
- Use type guards: Create functions that properly narrow types
- Leverage Effect's type utilities: Use
Effect.succeed,Effect.fail, etc. for type-safe operations - Handle external data safely: Always validate data from APIs, databases, or user input
Exceptions (VERY RARE):
- Legacy integration points: When interfacing with poorly-typed third-party libraries, use
unknownand immediate validation - Dynamic programming scenarios: Use Effect Schema to validate dynamic data structures
- Temporary migration: Mark with TODO comments and timeline for proper typing
Examples of proper type handling:
// ❌ Wrong - any and casting
const processApiResponse = (response: any) => {
const items = response.data.items as Item[];
return items.map((item) => item.name);
};
// ✅ Correct - Schema validation and Effect patterns
const ApiResponseSchema = Schema.Struct({
data: Schema.Struct({
items: Schema.Array(ItemSchema),
}),
});
const processApiResponse = Effect.fn("processApiResponse")(function* (
response: unknown
) {
const validatedResponse =
yield* Schema.decodeUnknown(ApiResponseSchema)(response);
return pipe(
validatedResponse.data.items,
Array.map((item) => item.name)
);
});
- Follow existing Effect-TS patterns
Error Handling Patterns
Tagged Errors:
- Always use
Schema.TaggedErrorinstead ofData.TaggedError - Use the correct syntax:
Schema.TaggedError<ErrorClass>()(tagName, fields) - Define error fields using Schema types (e.g.,
Schema.String,Schema.Unknown) - Use
Schema.optional()for optional fields - REQUIRED for
Effect.tryPromise: Always provide proper error handling with tagged errors
Error Logging:
- NEVER use
instanceof Errorchecks when logging or handling Effect errors - Effect errors are typed - use them directly without type checking or conversion
- Avoid:
error instanceof Error ? error.message : String(error) - Avoid:
error instanceof Error ? error.message : \${error}`` - Prefer: Just use the
errordirectly - Effect's error handling and logging will handle serialization - When storing errors in data structures: Store the error object directly, not converted strings
Example:
export class ValidationError extends Schema.TaggedError<ValidationError>()(
"ValidationError",
{
field: Schema.String,
message: Schema.String,
cause: Schema.optional(Schema.Unknown),
}
) {}
// Good error logging
Effect.tapError((error) =>
Effect.logError("Operation failed", {
error, // Log the typed error directly
context: "additional context",
})
);
// Bad error logging - DON'T DO THIS
Effect.tapError((error) =>
Effect.logError("Operation failed", {
error: error instanceof Error ? error.message : `${error}`, // ❌ Wrong!
context: "additional context",
})
);
Effect.tryPromise Requirements:
- ALWAYS use the object form with proper error handling
- NEVER use the simple function form:
Effect.tryPromise(() => promise) - ALWAYS provide tagged error handling: Use
Schema.TaggedErrorfor the catch function
// ❌ Wrong - No error handling
const loadData = Effect.tryPromise(() => fetch(url));
// ❌ Wrong - Generic error handling
const loadData = Effect.tryPromise({
try: () => fetch(url),
catch: (error) => error, // Generic error, not typed
});
// ✅ Correct - Proper tagged error handling
class NetworkError extends Schema.TaggedError<NetworkError>()("NetworkError", {
operation: Schema.String,
cause: Schema.optional(Schema.Unknown),
}) {}
const loadData = Effect.tryPromise({
try: () => fetch(url),
catch: (cause) => new NetworkError({ operation: "fetch", cause }),
});
Import Pattern:
import { Schema } from "effect";
// NOT: import { Data } from 'effect'
Key Components
Authentication
- Better Auth for authentication
- Custom RBAC system
- Session management in app layer
Database
- PostgreSQL with Drizzle ORM
- Effect integration for all operations
- Migrations in
packages/db/migrations/ - Schema in
packages/db/schema/
ChMS Adapters
- Each ChMS has dedicated adapter in
adapters/ - PCO adapter is most mature reference
- Built with Effect for error handling
- Use Effect's Layer system for composition
Sync Engine
- Located in
backend/workers/ - Effect workflows using
@effect/clusterand@effect/workflowpackages - Bi-directional sync with conflict resolution
- Effect's resource management for lifecycle
- NOT Temporal - uses Effect's native workflow system
Adding New Workflows
When creating new Effect workflows (using @effect/workflow), they must be registered in two key locations:
1. Workflow API Registration (backend/workers/api/workflowApi.ts):
- Import the workflow definition
- Add to the
workflowsarray to expose via HTTP API
2. Workflow Runner Registration (backend/workers/runner.ts):
- Import the workflow layer
- Add to the
EnvLayer.mergeAll()call for execution
Example Pattern:
// In workflowApi.ts
import { MyNewWorkflow } from "@openfaith/workers/workflows/myNewWorkflow";
export const workflows = [
ExternalSyncWorkflow,
PcoSyncEntityWorkflow,
ExternalSyncWorkflow,
ExternalSyncEntityWorkflow,
MyNewWorkflow, // Add here
TestWorkflow,
] as const;
// In runner.ts
import { MyNewWorkflowLayer } from "@openfaith/workers/workflows/myNewWorkflow";
const EnvLayer = Layer.mergeAll(
ExternalSyncWorkflowLayer,
PcoSyncEntityWorkflowLayer,
ExternalSyncWorkflowLayer,
ExternalSyncEntityWorkflowLayer,
MyNewWorkflowLayer, // Add here
TestWorkflowLayer
);
Workflow Structure Requirements:
- Export both the workflow definition and its layer
- Use
Workflow.make()from@effect/workflowto define workflows - Use Effect-TS patterns throughout (Effect.gen, pipe, etc.)
- Define proper error schemas using
Schema.TaggedError - Include proper logging and observability
- Follow the naming convention:
MyWorkflowandMyWorkflowLayer - Use
Activity.make()for activities within workflows - Leverage
@effect/clusterfor distributed execution
Client Sync
- Rocicorp Zero for local-first architecture
- Configuration in
packages/zero/ - Effect-RX for reactive frontend state
- Instant UI with eventual consistency
API Communication
- Effect HTTP and RPC (no tRPC)
- Direct Effect-based RPC calls
- Type safety through Effect Schema
- Consistent error handling across stack
Testing
Testing Philosophy
Write comprehensive tests that validate both type-level correctness and runtime behavior. This dual approach catches issues at compile time and ensures proper functionality.
Core Testing Principles
- Co-locate test files with source code
- Use Effect's testing utilities
- Test services with Effect's test runtime
- Use Effect's mock layer system
- Follow existing test patterns
- Aim for 100% test coverage on files you write tests for
- Use
bun test --coverageto generate coverage reports - When writing tests for a file, ensure all functions, branches, and edge cases are covered
- Coverage reports help identify untested code paths that need attention
- Use
Type-Level Testing
When working with complex type transformations (especially in adapters and API layers), include tests that validate the generated types work correctly:
Type Structure Validation:
- Test that generated types have the expected parameter structure
- Verify path parameters are in the correct positions for HTTP endpoints
- Ensure payload schemas match expected shapes
- Validate that type constraints are properly enforced
Mock Integration Testing:
- Create mock implementations that use the generated types
- Test that mock clients can be called with expected parameters
- Verify type safety prevents incorrect usage at compile time
Example Pattern:
// Test that PATCH endpoints have correct type structure
effect(
"Type validation: PATCH endpoints have path and payload parameters",
() =>
Effect.gen(function* () {
// Mock function that expects PATCH structure
const mockPatchCall = (params: {
path: { personId: string }; // Path params should be available
payload: {
data: {
type: string;
attributes: Record<string, unknown>;
};
};
}) => params;
// This should compile correctly - validates type structure
const result = mockPatchCall({
path: { personId: "456" },
payload: {
data: {
type: "Person",
attributes: { first_name: "Jane" },
},
},
});
expect(result.path.personId).toBe("456");
})
);
Runtime Testing
Functional Testing:
- Test actual business logic and data transformations
- Verify Effect workflows execute correctly
- Test error handling and recovery scenarios
- Validate schema parsing and validation
Integration Testing:
- Test adapter integrations with mock external services
- Verify database operations work correctly
- Test API endpoints with real request/response cycles
When to Use Each Approach
Type-Level Tests: Essential for
- Complex type transformations (like
ConvertPcoHttpApi) - Generic utilities that generate types
- API client generation
- Schema transformations
- Any code where type correctness is critical for runtime behavior
Runtime Tests: Essential for
- Business logic validation
- Data processing and transformations
- Error handling scenarios
- Integration points
- User-facing functionality
Test Organization
// Type-level tests - focus on compile-time correctness
effect("Type validation: endpoint parameter structure", () => {
/* ... */
});
// Runtime tests - focus on behavior
effect("Business logic: data transformation works correctly", () => {
/* ... */
});
// Integration tests - focus on system interactions
effect("Integration: adapter syncs data correctly", () => {
/* ... */
});
This comprehensive testing approach ensures both type safety and functional correctness, catching issues early and providing confidence in complex type-driven architectures.
Important Notes
Before Making Changes
- Always run
bun run typecheckafter changes - Use
bun run format:fixto fix formatting - Run
bun run checkfor comprehensive validation - Database changes require
bun run db:generate
Common Patterns
- Use
workspace:*for internal package references - Convert ALL async operations to Effect - no exceptions
- NO Promise-based code anywhere - use Effect equivalents
- Use Effect Schema for data validation
- Follow the existing adapter patterns for new integrations
- Use Effect's error handling instead of throwing exceptions
Async/Promise Conversion Guide
Converting Promises to Effect:
// ❌ Wrong - Promise-based
const fetchData = async (url: string) => {
const response = await fetch(url);
return response.json();
};
// ❌ Wrong - Effect.tryPromise without proper error handling
const fetchData = (url: string) =>
Effect.gen(function* () {
const response = yield* Effect.tryPromise(() => fetch(url));
const data = yield* Effect.tryPromise(() => response.json());
return data;
});
// ✅ Correct - Effect-based with proper tagged errors
class FetchError extends Schema.TaggedError<FetchError>()("FetchError", {
url: Schema.String,
cause: Schema.optional(Schema.Unknown),
}) {}
const fetchData = (url: string) =>
Effect.gen(function* () {
const response = yield* Effect.tryPromise({
try: () => fetch(url),
catch: (cause) => new FetchError({ url, cause }),
});
const data = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new FetchError({ url, cause }),
});
return data;
});
// ❌ Wrong - Promise.all
const loadMultiple = async () => {
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
return { a, b, c };
};
// ✅ Correct - Effect.all
const loadMultiple = Effect.gen(function* () {
const [a, b, c] = yield* Effect.all([fetchA(), fetchB(), fetchC()]);
return { a, b, c };
});
React Integration:
// ❌ Wrong - useEffect with async
useEffect(() => {
const loadData = async () => {
const data = await fetchData();
setData(data);
};
loadData();
}, []);
// ❌ Wrong - useEffect with Effect (anti-pattern)
useEffect(() => {
const program = pipe(
fetchData(),
Effect.tap((data) => Effect.sync(() => setData(data))),
Effect.catchAll((error) => Effect.logError("Failed to load data", error)),
);
Effect.runPromise(program);
}, []);
// ✅ Correct - Effect-RX with proper hooks
const dataRx = Rx.fn(() => fetchData());
const MyComponent = () => {
const query = useRxQuery(dataRx);
return pipe(
query.dataOpt,
Option.match({
onNone: () => <div>Loading...</div>,
onSome: (data) => <div>{data}</div>
})
);
};
No-Op Utilities
Use standardized no-op functions from @openfaith/shared instead of creating inline functions:
import {
nullOp,
noOp,
asyncNoOp,
asyncNullOp,
nullOpE,
} from "@openfaith/shared";
// ✅ Good - Use standardized no-ops
Option.match({
onNone: nullOp,
onSome: (value) => processValue(value),
});
// ❌ Avoid - Inline functions
Option.match({
onNone: () => null,
onSome: (value) => processValue(value),
});
Available No-Op Utilities:
nullOp: () => null- Returns null (most common)noOp: () => {}- Returns undefined/voidnullOpE: () => Effect.succeed(null)- Effect returning null- DEPRECATED:
asyncNoOpandasyncNullOp- usenullOpEinstead
Package Management
When adding new exports to any package:
-
Always update the index file: Add exports to the package's
index.tsfor any new components, utilities, or functions -
Use clean imports: Import from the package root (e.g.,
@openfaith/ui) instead of deep paths -
Export pattern: Follow the existing pattern in
index.ts:export * from "@openfaith/packagename/path/to/newComponent"; -
Import pattern: Use the clean import in consuming code:
// ✅ Good import { Button, Dialog, Separator } from "@openfaith/ui"; import { PersonSchema, GroupSchema } from "@openfaith/schema"; // ❌ Avoid import { Button } from "@openfaith/ui/components/ui/button"; import { PersonSchema } from "@openfaith/schema/directory/personSchema";
Special Cases:
- Adapters with client/server split: Some packages like
@openfaith/pcohave separateclient.tsandserver.tsentry points for code that can't be shared between environments - Environment-specific exports: Use separate entry points when exports contain server-only or client-only code
This keeps imports clean and makes it easier to refactor component locations later.
Component Migration Pattern
When moving components between packages to resolve architectural constraints:
✅ CORRECT PATTERN - Move, Nuke, Fix Imports:
- Move: Copy the component to the new location (e.g., from
apps/topackages/ui/) - Export: Add the component to the destination package's
index.ts - Nuke: Delete the original file completely - never leave re-export files
- Fix Imports: Update all import statements to use the new location
❌ ANTI-PATTERN - Re-export Files:
- NEVER create re-export files like
export { Component } from '@new/location' - NEVER leave stub files that just forward exports
- Re-export files create confusion, add indirection, and make refactoring harder
Example Migration:
// ❌ Wrong - Don't create re-export files
// apps/old-location/component.tsx
export { MyComponent } from "@openfaith/ui";
// ✅ Correct - Complete migration
// 1. Move component to packages/ui/components/myComponent.tsx
// 2. Add to packages/ui/index.ts: export * from '@openfaith/ui/components/myComponent'
// 3. Delete apps/old-location/component.tsx entirely
// 4. Update all imports: import { MyComponent } from '@openfaith/ui'
Benefits of Move-Nuke-Fix Pattern:
- Clear component ownership and location
- No indirection or confusion about where components live
- Easier to find and maintain components
- Cleaner import paths
- Prevents circular dependencies
Infrastructure Dependencies
- PostgreSQL database must be running
- Redis for caching and sessions
- OpenTelemetry for observability
- Use
bun run infrato start all services
This configuration ensures AI agents understand the Effect-TS first approach and can work effectively within the established patterns and architecture.
MCP Servers
- You can use effect-docs mcp to get access to any doc for Effect-TS.
- You can use context7 mcp to get access to docs from any other library.