UNPKG

oneie

Version:

Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.

1,708 lines (1,429 loc) 66.4 kB
--- title: Ontology Frontend Specifications dimension: knowledge category: ontology-frontend-specifications.md tags: 6-dimensions, architecture, backend, frontend, ontology, ui related_dimensions: events, groups, people scope: global created: 2025-11-03 updated: 2025-11-03 version: 1.0.0 ai_context: | This document is part of the knowledge dimension in the ontology-frontend-specifications.md category. Location: one/knowledge/ontology-frontend-specifications.md Purpose: Documents one ontology: frontend implementation guide Related dimensions: events, groups, people For AI agents: Read this to understand ontology frontend specifications. --- # ONE Ontology: Frontend Implementation Guide **Building /frontend/* - The Rendering Layer** --- ## Executive Summary The frontend is **purely a rendering and interaction layer**. It has ZERO business logic, ZERO database access, ZERO data validation. It only: 1. **Renders UI** from data 2. **Calls backend APIs** via abstract data providers 3. **Manages local UI state** (loading, errors, forms) 4. **Displays real-time updates** via subscriptions **Backend-Agnostic Architecture:** ``` ┌────────────────────────────────────────────────────────┐ │ FRONTEND (Astro + React) │ │ ✅ Renders HTML/React components │ │ ✅ Calls DataProvider interface │ │ ✅ Manages UI state only │ │ ✅ Displays data from any backend │ │ ❌ NO database access │ │ ❌ NO business logic │ │ ❌ NO data validation │ │ ❌ NO event logging │ └──────────────────────┬─────────────────────────────────┘ │ DataProvider Interface │ (Ontology = Universal API) ↓ ┌────────────────────────────────────────────────────────┐ │ BACKEND PROVIDERS (Choose One) │ │ │ │ ├─ ConvexProvider → Convex backend │ │ ├─ WordPressProvider → WordPress + WooCommerce │ │ ├─ NotionProvider → Notion databases │ │ ├─ SupabaseProvider → Supabase PostgreSQL │ │ ├─ StrapiProvider → Strapi CMS │ │ └─ CustomProvider → Your own API │ │ │ │ All providers implement the same interface: │ │ - organizations: { get, list, update } │ │ - people: { get, list, create, update, delete } │ │ - things: { get, list, create, update, delete } │ │ - connections: { create, getRelated, getCount } │ │ - events: { log, query } │ │ - knowledge: { embed, search } │ └────────────────────────────────────────────────────────┘ ``` **Key Insight:** The ONE 6-dimension ontology becomes a **universal data API**. Frontend doesn't care if data comes from Convex, WordPress, or Notion—it only knows the 6 dimensions. **What Frontend Builds:** - 66 thing types → 198 UI components (card, list, detail per type) - 25 connection types → 75 relationship UIs - Organizations → Multi-tenant websites at `{slug}.one.ie` **Tech Stack:** - **Astro** - SSR/SSG (pages, layouts) - **React 19** - Interactive components - **Effect.ts** - Type-safe, composable data operations - **DataProvider Pattern** - Backend-agnostic interface (inspired by Astro's content layer) - **Tailwind + shadcn/ui** - Styling - **TypeScript** - Type safety **Architecture Philosophy:** 1. **Backend-Agnostic** - Frontend talks to `DataProvider` interface, not specific backends - Swap backends by changing ONE line in config - Ontology = universal API that works with ANY backend 2. **Provider Pattern** - `ConvexProvider`, `WordPressProvider`, `NotionProvider`, etc. - Each provider translates ontology operations → backend-specific calls - Organizations can use their existing infrastructure 3. **Generic Services** - `ThingService` + `ConnectionService` handle ALL 66 thing types - No specialized services needed (courses, products, lessons are all things) - Add convenience wrappers only when you repeat patterns 3+ times --- ## Table of Contents 1. [What Frontend IS and IS NOT](#what-frontend-is-and-is-not) 2. [Frontend File Structure](#frontend-file-structure) 3. [Backend-Agnostic Data Layer](#backend-agnostic-data-layer) 4. [DataProvider Interface](#dataprovider-interface) 5. [Provider Implementations](#provider-implementations) 6. [Configuration (Astro-Style)](#configuration-astro-style) 7. [Ontology Config (Display Only)](#ontology-config-display-only) 8. [Effect.ts Service Layer](#effectts-service-layer) 9. [React Integration (useEffectRunner)](#react-integration-useeffectrunner) 10. [Component Patterns](#component-patterns) 11. [Page Patterns (SSR)](#page-patterns-ssr) 12. [Real-Time Components](#real-time-components) 13. [Forms (Calls Backend)](#forms-calls-backend) 14. [Multi-Tenant Routing](#multi-tenant-routing) 15. [Deployment](#deployment) --- ## What Frontend IS and IS NOT ### ✅ Frontend IS Responsible For: **1. Rendering** ```tsx // Display data from backend <h1>{course.name}</h1> <p>{course.properties.description}</p> ``` **2. Calling Backend APIs** ```tsx // Get data from backend const courses = useQuery(api.queries.things.list, { type: 'course' }) // Send data to backend const create = useMutation(api.mutations.things.create) await create({ type: 'course', name: 'Fitness 101', properties: {...} }) ``` **3. Managing UI State** ```tsx const [loading, setLoading] = useState(false) const [error, setError] = useState(null) ``` **4. User Interactions** ```tsx <button onClick={handleClick}>Create Course</button> <input onChange={handleChange} /> ``` **5. Client-Side Routing** ```tsx // Navigate between pages <Link href="/courses">View Courses</Link> ``` ### ❌ Frontend IS NOT Responsible For: **1. Database Operations** ```tsx // ❌ NEVER do this in frontend const courses = await db.query('things').filter(...).collect() ``` **2. Business Logic** ```tsx // ❌ NEVER do this in frontend if (user.tokens < course.price) { // Calculate discount, check quotas, etc. } ``` **3. Data Validation** ```tsx // ❌ NEVER trust frontend validation alone if (formData.email.includes('@')) { // Backend MUST validate, frontend only for UX } ``` **4. Event Logging** ```tsx // ❌ NEVER log events in frontend await db.insert('events', { type: 'course_created', ... }) ``` **5. Authorization** ```tsx // ❌ NEVER check permissions in frontend alone if (user.role === 'admin') { // Backend MUST authorize, frontend only for UI hints } ``` --- ## Frontend File Structure ``` frontend/ # Frontend repo (separate from backend) ├── src/ │ ├── pages/ # Astro pages (SSR/SSG) │ │ ├── index.astro # Homepage │ │ ├── courses/ │ │ │ ├── index.astro # Course list (SSR) │ │ │ └── [id].astro # Course detail (SSR) │ │ └── [thingType]/ # Dynamic routes │ │ ├── index.astro # Generic list page │ │ └── [id].astro # Generic detail page │ ├── components/ # React components │ │ ├── cards/ │ │ │ └── ThingCard.tsx # Generic card (adapts to any type) │ │ ├── lists/ │ │ │ └── ThingList.tsx # Generic list │ │ ├── forms/ │ │ │ └── ThingForm.tsx # Generic form (calls backend) │ │ └── ui/ # shadcn/ui components │ ├── services/ # Effect.ts client services │ │ ├── ConvexHttpClient.ts # Convex client wrapper (required) │ │ ├── ThingClientService.ts # Thing operations (required) │ │ ├── ConnectionClientService.ts # Connection operations (required) │ │ ├── ClientLayer.ts # Dependency injection layer (required) │ │ └── CourseClientService.ts # OPTIONAL: Course-specific convenience │ ├── hooks/ # React hooks │ │ └── useEffectRunner.ts # Run Effect programs in React │ ├── layouts/ │ │ └── Layout.astro # Main layout │ ├── ontology/ # Display config ONLY │ │ ├── types.ts # Type definitions (synced from backend) │ │ └── config.ts # UI display config (colors, icons, labels) │ ├── lib/ │ │ └── convex.ts # Backend client setup │ └── middleware.ts # Multi-tenant routing ├── .env │ PUBLIC_CONVEX_URL=https://backend.convex.cloud # Backend URL └── package.json ``` **Key Point:** Frontend has NO `convex/` directory. Backend-specific code lives in provider implementations. --- ## Backend-Agnostic Data Layer **Inspired by Astro's Content Layer Pattern** Astro elegantly abstracts content sources via a universal interface: ```typescript // astro.config.ts import { defineConfig } from 'astro/config' import { glob } from 'astro/loaders' export default defineConfig({ collections: { blog: { loader: glob({ pattern: '**/*.md', base: './src/content/blog' }) } } }) ``` ONE applies this pattern to **data operations**: ```typescript // frontend/astro.config.ts import { defineConfig } from 'astro/config' import { convexProvider } from './src/providers/convex' // import { wordpressProvider } from './src/providers/wordpress' // import { notionProvider } from './src/providers/notion' export default defineConfig({ integrations: [ one({ // ✅ Change this ONE line to swap backends provider: convexProvider({ url: import.meta.env.PUBLIC_BACKEND_URL }) // Or use WordPress: // provider: wordpressProvider({ // url: 'https://yoursite.com', // apiKey: import.meta.env.WORDPRESS_API_KEY // }) // Or use Notion: // provider: notionProvider({ // apiKey: import.meta.env.NOTION_API_KEY, // databaseId: import.meta.env.NOTION_DB_ID // }) }) ] }) ``` **Key Principle:** Frontend components never know which backend they're talking to. --- ## DataProvider Interface **The universal ontology API.** ```typescript // frontend/src/providers/DataProvider.ts import { Effect } from 'effect' // Error types (universal across all providers) export class ThingNotFoundError { readonly _tag = 'ThingNotFoundError' constructor(readonly thingId: string) {} } export class ConnectionCreateError { readonly _tag = 'ConnectionCreateError' constructor(readonly reason: string) {} } export class UnauthorizedError { readonly _tag = 'UnauthorizedError' } export class GroupNotFoundError { readonly _tag = 'GroupNotFoundError' constructor(readonly groupId: string) {} } export class PersonNotFoundError { readonly _tag = 'PersonNotFoundError' constructor(readonly personId: string) {} } export class PersonCreateError { readonly _tag = 'PersonCreateError' constructor(readonly reason: string) {} } // Type definitions for 6 dimensions export interface Group { _id: string slug: string name: string type: 'friend_circle' | 'business' | 'community' | 'dao' | 'government' | 'organization' parentGroupId?: string description?: string status: 'active' | 'archived' settings: { visibility: 'public' | 'private' joinPolicy: 'open' | 'invite_only' | 'approval_required' plan?: 'starter' | 'pro' | 'enterprise' } createdAt: number updatedAt: number } export interface Person { _id: string email: string username: string displayName: string emailVerified: boolean role: 'platform_owner' | 'group_owner' | 'group_user' | 'customer' groupId?: string groups: string[] // All groups this person belongs to permissions?: string[] image?: string bio?: string createdAt: number updatedAt: number } // DataProvider interface (every backend must implement this) export interface DataProvider { // Dimension 1: Groups operations groups: { get: (id: string) => Effect.Effect<Group, GroupNotFoundError> list: (params: { status?: 'active' | 'archived' limit?: number }) => Effect.Effect<Group[], Error> update: (id: string, updates: Partial<Group>) => Effect.Effect<void, Error> } // Dimension 2: People operations people: { get: (id: string) => Effect.Effect<Person, PersonNotFoundError | UnauthorizedError> list: (params: { groupId?: string role?: 'platform_owner' | 'group_owner' | 'group_user' | 'customer' filters?: Record<string, any> limit?: number }) => Effect.Effect<Person[], Error> create: (input: { email: string displayName: string role: 'platform_owner' | 'group_owner' | 'group_user' | 'customer' groupId: string password?: string }) => Effect.Effect<string, PersonCreateError> update: (id: string, updates: Partial<Person>) => Effect.Effect<void, Error> delete: (id: string) => Effect.Effect<void, Error> } // Dimension 3: Things operations things: { get: (id: string) => Effect.Effect<Thing, ThingNotFoundError | UnauthorizedError> list: (params: { type: ThingType groupId?: string filters?: Record<string, any> limit?: number }) => Effect.Effect<Thing[], Error> create: (input: { type: ThingType name: string groupId: string properties: Record<string, any> }) => Effect.Effect<string, ThingCreateError> update: (id: string, updates: Partial<Thing>) => Effect.Effect<void, Error> delete: (id: string) => Effect.Effect<void, Error> } // Dimension 4: Connections operations connections: { create: (input: { fromThingId: string toThingId: string relationshipType: ConnectionType metadata?: Record<string, any> }) => Effect.Effect<string, ConnectionCreateError> getRelated: (params: { thingId: string relationshipType: ConnectionType direction: 'from' | 'to' | 'both' }) => Effect.Effect<Thing[], Error> getCount: (thingId: string, relationshipType: ConnectionType) => Effect.Effect<number, Error> delete: (id: string) => Effect.Effect<void, Error> } // Dimension 5: Events operations events: { log: (event: { type: EventType actorId: string // Person ID (who did it) targetId?: string // Thing/Person/Connection ID (what was acted upon) groupId: string // Group scope metadata?: Record<string, any> }) => Effect.Effect<void, Error> query: (params: { type?: EventType actorId?: string // Person ID targetId?: string groupId?: string from?: Date to?: Date }) => Effect.Effect<Event[], Error> } // Dimension 6: Knowledge operations knowledge: { embed: (params: { text: string sourceThingId?: string // Thing ID sourcePersonId?: string // Person ID groupId: string labels?: string[] }) => Effect.Effect<string, Error> search: (params: { query: string groupId?: string limit?: number }) => Effect.Effect<KnowledgeMatch[], Error> } // Real-time subscriptions (optional - not all backends support this) subscriptions?: { watchThing: (id: string) => Effect.Effect<Observable<Thing>, Error> watchList: (type: ThingType, groupId?: string) => Effect.Effect<Observable<Thing[]>, Error> } } ``` **Key Insight:** This interface IS the ONE 6-dimension ontology. Any backend that implements this interface can power ONE—Organizations partition, People authorize, Things exist, Connections relate, Events record, Knowledge understands. --- ## Provider Implementations ### Convex Provider ```typescript // frontend/src/providers/convex/ConvexProvider.ts import { Effect, Layer } from 'effect' import { ConvexHttpClient } from 'convex/browser' import { DataProvider } from '../DataProvider' import { api } from './api' export class ConvexProvider implements DataProvider { constructor(private client: ConvexHttpClient) {} things = { get: (id: string) => Effect.gen(function* () { const thing = yield* Effect.tryPromise({ try: () => this.client.query(api.queries.things.get, { id }), catch: (error) => new Error(String(error)) }) if (!thing) { return yield* Effect.fail(new ThingNotFoundError(id)) } return thing }), list: (params) => Effect.tryPromise({ try: () => this.client.query(api.queries.things.list, params), catch: (error) => new Error(String(error)) }), create: (input) => Effect.tryPromise({ try: () => this.client.mutation(api.mutations.things.create, input), catch: (error) => new ConnectionCreateError(String(error)) }), update: (id, updates) => Effect.tryPromise({ try: () => this.client.mutation(api.mutations.things.update, { id, updates }), catch: (error) => new Error(String(error)) }), delete: (id) => Effect.tryPromise({ try: () => this.client.mutation(api.mutations.things.delete, { id }), catch: (error) => new Error(String(error)) }) } connections = { // Similar implementation for connections... } events = { // Similar implementation for events... } knowledge = { // Similar implementation for knowledge... } } // Factory function export function convexProvider(config: { url: string }) { return Layer.succeed( DataProvider, new ConvexProvider(new ConvexHttpClient(config.url)) ) } ``` ### WordPress Provider ```typescript // frontend/src/providers/wordpress/WordPressProvider.ts import { Effect, Layer } from 'effect' import { DataProvider } from '../DataProvider' export class WordPressProvider implements DataProvider { constructor( private baseUrl: string, private apiKey: string ) {} things = { get: (id: string) => Effect.gen(function* () { // Map ONE ontology → WordPress REST API const response = yield* Effect.tryPromise({ try: () => fetch(`${this.baseUrl}/wp-json/wp/v2/posts/${id}`, { headers: { Authorization: `Bearer ${this.apiKey}` } }), catch: (error) => new Error(String(error)) }) if (!response.ok) { return yield* Effect.fail(new ThingNotFoundError(id)) } const post = yield* Effect.tryPromise({ try: () => response.json(), catch: (error) => new Error(String(error)) }) // Transform WordPress post → ONE thing return { _id: post.id.toString(), type: 'post' as ThingType, name: post.title.rendered, properties: { content: post.content.rendered, excerpt: post.excerpt.rendered, author: post.author, publishedAt: post.date }, status: post.status, createdAt: new Date(post.date).getTime(), updatedAt: new Date(post.modified).getTime() } }), list: (params) => Effect.gen(function* () { // Map ONE list query → WordPress REST API query const query = new URLSearchParams({ per_page: String(params.limit || 10), // Map ontology filters → WordPress query params }) const response = yield* Effect.tryPromise({ try: () => fetch(`${this.baseUrl}/wp-json/wp/v2/posts?${query}`, { headers: { Authorization: `Bearer ${this.apiKey}` } }), catch: (error) => new Error(String(error)) }) const posts = yield* Effect.tryPromise({ try: () => response.json(), catch: (error) => new Error(String(error)) }) // Transform WordPress posts → ONE things return posts.map((post: any) => ({ _id: post.id.toString(), type: 'post' as ThingType, name: post.title.rendered, properties: { content: post.content.rendered, excerpt: post.excerpt.rendered }, status: post.status, createdAt: new Date(post.date).getTime(), updatedAt: new Date(post.modified).getTime() })) }), create: (input) => Effect.gen(function* () { // Map ONE create → WordPress create post const response = yield* Effect.tryPromise({ try: () => fetch(`${this.baseUrl}/wp-json/wp/v2/posts`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` }, body: JSON.stringify({ title: input.name, content: input.properties.content || '', status: 'draft' }) }), catch: (error) => new ConnectionCreateError(String(error)) }) const post = yield* Effect.tryPromise({ try: () => response.json(), catch: (error) => new ConnectionCreateError(String(error)) }) return post.id.toString() }), update: (id, updates) => { // Similar WordPress update implementation }, delete: (id) => { // Similar WordPress delete implementation } } connections = { // WordPress doesn't have native connections // Could use: // - Post relationships (parent/child) // - Categories/tags as connections // - Custom post meta // - Custom tables } events = { // Log to WordPress activity log or custom table } knowledge = { // Could integrate with: // - Elasticsearch // - Algolia // - Custom vector search } } // Factory function export function wordpressProvider(config: { url: string; apiKey: string }) { return Layer.succeed( DataProvider, new WordPressProvider(config.url, config.apiKey) ) } ``` ### Notion Provider ```typescript // frontend/src/providers/notion/NotionProvider.ts import { Effect, Layer } from 'effect' import { Client } from '@notionhq/client' import { DataProvider } from '../DataProvider' export class NotionProvider implements DataProvider { private notion: Client constructor(apiKey: string, private databaseId: string) { this.notion = new Client({ auth: apiKey }) } things = { get: (id: string) => Effect.gen(function* () { // Map ONE thing → Notion page const page = yield* Effect.tryPromise({ try: () => this.notion.pages.retrieve({ page_id: id }), catch: (error) => new Error(String(error)) }) // Transform Notion page → ONE thing return { _id: page.id, type: 'document' as ThingType, name: (page.properties.Name as any).title[0]?.plain_text || '', properties: { // Map Notion properties → ONE properties }, status: 'active', createdAt: new Date(page.created_time).getTime(), updatedAt: new Date(page.last_edited_time).getTime() } }), list: (params) => Effect.gen(function* () { // Map ONE list query → Notion database query const response = yield* Effect.tryPromise({ try: () => this.notion.databases.query({ database_id: this.databaseId, page_size: params.limit || 10 }), catch: (error) => new Error(String(error)) }) // Transform Notion pages → ONE things return response.results.map((page: any) => ({ _id: page.id, type: params.type, name: page.properties.Name?.title[0]?.plain_text || '', properties: {}, status: 'active', createdAt: new Date(page.created_time).getTime(), updatedAt: new Date(page.last_edited_time).getTime() })) }), create: (input) => Effect.gen(function* () { // Map ONE create → Notion create page const page = yield* Effect.tryPromise({ try: () => this.notion.pages.create({ parent: { database_id: this.databaseId }, properties: { Name: { title: [{ text: { content: input.name } }] } // Map input.properties → Notion properties } }), catch: (error) => new ConnectionCreateError(String(error)) }) return page.id }), update: (id, updates) => { // Similar Notion update implementation }, delete: (id) => { // Notion archive page } } connections = { // Notion relations can map to connections } events = { // Log to separate Notion database } knowledge = { // Use Notion's built-in search or external vector DB } } // Factory function export function notionProvider(config: { apiKey: string; databaseId: string }) { return Layer.succeed( DataProvider, new NotionProvider(config.apiKey, config.databaseId) ) } ``` ### Supabase Provider ```typescript // frontend/src/providers/supabase/SupabaseProvider.ts import { Effect, Layer } from 'effect' import { createClient, SupabaseClient } from '@supabase/supabase-js' import { DataProvider } from '../DataProvider' export class SupabaseProvider implements DataProvider { private supabase: SupabaseClient constructor(url: string, apiKey: string) { this.supabase = createClient(url, apiKey) } things = { get: (id: string) => Effect.gen(function* () { // Map ONE thing → Supabase row const { data, error } = yield* Effect.tryPromise({ try: () => this.supabase.from('things').select('*').eq('id', id).single(), catch: (err) => new Error(String(err)) }) if (error) { return yield* Effect.fail(new ThingNotFoundError(id)) } return data }), list: (params) => Effect.gen(function* () { let query = this.supabase .from('things') .select('*') .eq('type', params.type) .limit(params.limit || 10) if (params.groupId) { query = query.eq('groupId', params.groupId) } const { data, error } = yield* Effect.tryPromise({ try: () => query, catch: (err) => new Error(String(err)) }) if (error) { return yield* Effect.fail(new Error(error.message)) } return data }), create: (input) => Effect.gen(function* () { const { data, error } = yield* Effect.tryPromise({ try: () => this.supabase.from('things').insert([input]).select().single(), catch: (err) => new ConnectionCreateError(String(err)) }) if (error) { return yield* Effect.fail(new ConnectionCreateError(error.message)) } return data.id }), update: (id, updates) => { // Similar Supabase update }, delete: (id) => { // Similar Supabase delete } } connections = { // Use Supabase 'connections' table } events = { // Use Supabase 'events' table } knowledge = { // Use pgvector extension for embeddings } // ✅ Supabase supports real-time! subscriptions = { watchThing: (id: string) => Effect.gen(function* () { // Use Supabase real-time subscriptions const channel = this.supabase .channel(`thing:${id}`) .on('postgres_changes', { event: '*', schema: 'public', table: 'things', filter: `id=eq.${id}` }, (payload) => { // Emit updates }) yield* Effect.tryPromise({ try: () => channel.subscribe(), catch: (err) => new Error(String(err)) }) // Return observable }) } } // Factory function export function supabaseProvider(config: { url: string; apiKey: string }) { return Layer.succeed( DataProvider, new SupabaseProvider(config.url, config.apiKey) ) } ``` **Key Insight:** Each provider translates between ONE ontology ↔ backend-specific API. --- ## Configuration (Astro-Style) **Swap backends by changing ONE line.** ```typescript // frontend/astro.config.ts import { defineConfig } from 'astro/config' import react from '@astrojs/react' import { one } from '@one/astro-integration' // Import providers import { convexProvider } from './src/providers/convex' import { wordpressProvider } from './src/providers/wordpress' import { notionProvider } from './src/providers/notion' import { supabaseProvider } from './src/providers/supabase' export default defineConfig({ integrations: [ react(), one({ // ✅ Choose your backend (change this ONE line) // Option 1: Convex (real-time, serverless) provider: convexProvider({ url: import.meta.env.PUBLIC_CONVEX_URL }) // Option 2: WordPress (existing CMS) // provider: wordpressProvider({ // url: 'https://yoursite.com', // apiKey: import.meta.env.WORDPRESS_API_KEY // }) // Option 3: Notion (databases as backend) // provider: notionProvider({ // apiKey: import.meta.env.NOTION_API_KEY, // databaseId: import.meta.env.NOTION_DB_ID // }) // Option 4: Supabase (PostgreSQL + real-time) // provider: supabaseProvider({ // url: import.meta.env.PUBLIC_SUPABASE_URL, // apiKey: import.meta.env.PUBLIC_SUPABASE_ANON_KEY // }) // Option 5: Custom backend // provider: customProvider({ ... }) }) ] }) ``` **Result:** - Frontend components don't change - UI stays the same - Only data source changes - Organizations can use their existing infrastructure --- ## Ontology Config (Display Only) Frontend needs **display configuration** for UI rendering. NOT business logic. ```typescript // frontend/src/ontology/config.ts // THIS IS NOT BUSINESS LOGIC - JUST UI CONFIGURATION export const thingConfigs = { course: { // Display names displayName: 'Course', displayNamePlural: 'Courses', // UI presentation icon: 'BookOpen', // Lucide icon name color: 'green', // Tailwind color // Which fields to show in UI primaryField: 'title', // Main display field secondaryField: 'description', imageField: 'thumbnail', // Form fields (for UI only, backend validates) fields: { title: { label: 'Course Title', type: 'text', required: true, placeholder: 'e.g., Fitness 101' }, description: { label: 'Description', type: 'textarea', required: true }, price: { label: 'Price (USD)', type: 'number', required: true } } }, creator: { displayName: 'Creator', displayNamePlural: 'Creators', icon: 'User', color: 'blue', primaryField: 'name', secondaryField: 'bio', imageField: 'avatar', fields: { name: { label: 'Name', type: 'text', required: true }, email: { label: 'Email', type: 'text', required: true }, bio: { label: 'Bio', type: 'textarea', required: false } } } // ... all 66 types (UI config only) } // Helper function export function getThingConfig(type: ThingType) { return thingConfigs[type] } ``` **What This Is:** - ✅ UI labels and placeholders - ✅ Icon and color choices - ✅ Which fields to display - ✅ Form field types **What This Is NOT:** - ❌ Business logic - ❌ Validation rules (backend validates) - ❌ Authorization rules (backend authorizes) - ❌ Database schema (that's in backend) --- ## Effect.ts Service Layer **Effect.ts services consume the DataProvider interface.** The frontend service layer sits between React components and the DataProvider, providing: - Type-safe operations - Composable workflows - Consistent error handling - Testability ### Architecture ``` ┌────────────────────────────────────────────┐ │ React Components │ │ - useEffectRunner hook │ │ - Render UI from Effect results │ │ - Handle typed errors │ └────────────────┬───────────────────────────┘ │ Effect.runPromise ↓ ┌────────────────────────────────────────────┐ │ Service Layer (Effect.ts) │ │ - ThingService │ │ - ConnectionService │ │ - Composable, testable │ └────────────────┬───────────────────────────┘ │ DataProvider Interface ↓ ┌────────────────────────────────────────────┐ │ DataProvider (Backend-Agnostic) │ │ - ConvexProvider │ │ - WordPressProvider │ │ - NotionProvider │ │ - SupabaseProvider │ └────────────────────────────────────────────┘ ``` ### Why Effect.ts? ```typescript // ❌ Traditional approach - untyped errors, hard to compose async function getCourse(id: string) { try { const response = await fetch(`/api/courses/${id}`) const course = await response.json() return course } catch (error) { // What type of error? Network? 404? 500? console.error(error) throw error } } // ✅ Effect.ts approach - typed errors, backend-agnostic import { Effect } from 'effect' function getCourse(id: string) { return Effect.gen(function* () { const provider = yield* DataProvider // Works with ANY backend (Convex, WordPress, Notion, etc.) const course = yield* provider.things.get(id) return course }).pipe( // Handle specific errors Effect.catchTag('ThingNotFoundError', err => Effect.fail(new Error(`Course ${err.thingId} not found`)) ) ) } ``` **Key Benefits:** 1. **Backend-Agnostic** - Works with any DataProvider implementation 2. **Type-Safe Errors** - Compiler enforces error handling 3. **Composability** - Small functions combine into complex flows 4. **Testability** - Test layers replace real providers --- ## Service Implementations **Client services wrap backend API calls in Effect.ts patterns.** ### Core Principle: Generic Services Handle Everything The ontology has **66 thing types**, but you only need **2 generic services**: 1. **ThingClientService** - All CRUD for all 66 types 2. **ConnectionClientService** - All relationships ```typescript // ✅ This handles courses, lessons, products, everything // Works with ANY backend (Convex, WordPress, Notion, Supabase, etc.) const provider = yield* DataProvider // Get any thing const course = yield* provider.things.get(courseId) const lesson = yield* provider.things.get(lessonId) // List any type const courses = yield* provider.things.list({ type: 'course', groupId: orgId }) const products = yield* provider.things.list({ type: 'product', groupId: orgId }) // Create any type const courseId = yield* provider.things.create({ type: 'course', name: 'Fitness 101', properties: { price: 99, description: '...' } }) ``` **Specialized services are OPTIONAL** - only add them if you repeat the same multi-step operations frequently. --- ### Pattern: Generic Thing Service (Thin Wrapper) **Services are thin wrappers around DataProvider - backend-agnostic.** ```typescript // frontend/src/services/ThingService.ts import { Effect } from 'effect' import { DataProvider } from '@/providers/DataProvider' // Services just delegate to the configured provider export class ThingService extends Effect.Service<ThingService>()( 'ThingService', { effect: Effect.gen(function* () { // Get the configured provider (Convex, WordPress, etc.) const provider = yield* DataProvider return { // All methods delegate to provider get: (id: string) => provider.things.get(id), list: (type: ThingType, orgId?: string) => provider.things.list({ type, groupId: orgId }), create: (input: { type: ThingType; name: string; properties: any }) => provider.things.create(input), update: (id: string, updates: any) => provider.things.update(id, updates), delete: (id: string) => provider.things.delete(id) } }), dependencies: [DataProvider] // Depends on DataProvider (backend-agnostic) } ) {} ``` **Key Point:** `ThingService` doesn't care if provider is Convex, WordPress, or Notion. It just calls `provider.things.get()`. The provider handles backend-specific logic. ### Pattern: Specialized Service (Optional - Course Example) **You don't need this!** A course is just a thing. But if you repeat operations, add convenience methods: ```typescript // frontend/src/services/CourseClientService.ts (OPTIONAL) import { Effect } from 'effect' import { ThingClientService } from './ThingClientService' import { ConnectionClientService } from './ConnectionClientService' export class CourseClientService extends Effect.Service<CourseClientService>()( 'CourseClientService', { effect: Effect.gen(function* () { const thingService = yield* ThingClientService const connectionService = yield* ConnectionClientService return { // Convenience: Get course with lessons // Without this, you'd do: // const course = yield* thingService.get(courseId) // const lessons = yield* connectionService.getRelated(courseId, 'part_of', 'to') getCourseWithLessons: (courseId: string) => Effect.gen(function* () { const [course, lessons] = yield* Effect.all([ thingService.get(courseId), connectionService.getRelated(courseId, 'part_of', 'to') ]) return { course, lessons } }), // Convenience: Clearer domain vocabulary // Without this, you'd do: // yield* connectionService.create({ // fromThingId: userId, // toThingId: courseId, // relationshipType: 'enrolled_in' // }) enrollUser: (userId: string, courseId: string) => Effect.gen(function* () { return yield* connectionService.create({ fromThingId: userId, toThingId: courseId, relationshipType: 'enrolled_in' }) }), // Convenience: Specific count query getEnrollmentCount: (courseId: string) => Effect.gen(function* () { return yield* connectionService.getCount(courseId, 'enrolled_in') }) } }), dependencies: [ThingClientService.Default, ConnectionClientService.Default] } ) {} ``` **When to add specialized services:** - ✅ Repeat the same multi-step operation 3+ times - ✅ Want domain vocabulary (`enrollUser` vs `create connection`) - ✅ Need to encapsulate complex business workflows - ❌ Don't add them upfront for all 66 types - ❌ Don't add them for simple CRUD (use ThingClientService) ### ConvexHttpClient Wrapper ```typescript // frontend/src/services/ConvexHttpClient.ts import { Effect, Context, Layer } from 'effect' import { ConvexHttpClient as ConvexClient } from 'convex/browser' // Define client interface export class ConvexHttpClient extends Context.Tag('ConvexHttpClient')< ConvexHttpClient, { query: <T>(endpoint: any, args: any) => Effect.Effect<T, Error> mutation: <T>(endpoint: any, args: any) => Effect.Effect<T, Error> } >() {} // Live implementation export const ConvexHttpClientLive = Layer.succeed( ConvexHttpClient, { query: (endpoint, args) => Effect.tryPromise({ try: () => { const client = new ConvexClient(import.meta.env.PUBLIC_CONVEX_URL) return client.query(endpoint, args) }, catch: (error) => new Error(String(error)) }), mutation: (endpoint, args) => Effect.tryPromise({ try: () => { const client = new ConvexClient(import.meta.env.PUBLIC_CONVEX_URL) return client.mutation(endpoint, args) }, catch: (error) => new Error(String(error)) }) } ) ``` ### Client Layer (Dependency Injection) ```typescript // frontend/src/services/ClientLayer.ts import { Layer } from 'effect' import { ConvexHttpClientLive } from './ConvexHttpClient' import { ThingClientService } from './ThingClientService' import { ConnectionClientService } from './ConnectionClientService' // import { CourseClientService } from './CourseClientService' // OPTIONAL // ✅ Minimal layer - handles all 66 thing types export const ClientLayer = Layer.mergeAll( ConvexHttpClientLive, ThingClientService.Default, ConnectionClientService.Default // CourseClientService.Default // Add ONLY if you created it ) ``` **Most apps only need the 2 generic services.** Add specialized services as you discover repeated patterns. --- ## React Integration (useEffectRunner) **Custom hook for running Effect programs in React components.** ### useEffectRunner Hook ```typescript // frontend/src/hooks/useEffectRunner.ts import { useCallback, useState } from 'react' import { Effect } from 'effect' import { ClientLayer } from '@/services/ClientLayer' export function useEffectRunner() { const [loading, setLoading] = useState(false) const [error, setError] = useState<string | null>(null) const run = useCallback(async <A, E>( effect: Effect.Effect<A, E>, options?: { onSuccess?: (result: A) => void onError?: (error: E) => void } ) => { setLoading(true) setError(null) try { const result = await Effect.runPromise( effect.pipe(Effect.provide(ClientLayer)) ) options?.onSuccess?.(result) return result } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err) setError(errorMessage) options?.onError?.(err as E) throw err } finally { setLoading(false) } }, []) return { run, loading, error } } ``` ### Using in Components ```tsx // frontend/src/components/CourseEnrollButton.tsx import { useEffectRunner } from '@/hooks/useEffectRunner' import { ConnectionClientService } from '@/services/ConnectionClientService' import { Effect } from 'effect' import { Button } from '@/components/ui/button' import { Alert } from '@/components/ui/alert' interface Props { courseId: string userId: string } export function CourseEnrollButton({ courseId, userId }: Props) { const { run, loading, error } = useEffectRunner() const handleEnroll = () => { // Define Effect program using GENERIC service const program = Effect.gen(function* () { const connectionService = yield* ConnectionClientService // Enroll = create connection (course is just a thing) const enrollmentId = yield* connectionService.create({ fromThingId: userId, toThingId: courseId, relationshipType: 'enrolled_in' }) // Log success (client-side only for UX) yield* Effect.logInfo(`Enrolled in course ${courseId}`) return enrollmentId }) // Run Effect program run(program, { onSuccess: (enrollmentId) => { console.log('Enrolled successfully:', enrollmentId) window.location.href = `/courses/${courseId}/learn` }, onError: (err) => { console.error('Enrollment failed:', err) } }) } return ( <div> <Button onClick={handleEnroll} disabled={loading}> {loading ? 'Enrolling...' : 'Enroll Now'} </Button> {error && ( <Alert variant="destructive">{error}</Alert> )} </div> ) } ``` **Alternative with specialized service (if you created CourseClientService):** ```tsx // OPTIONAL: If you have CourseClientService const program = Effect.gen(function* () { const courseService = yield* CourseClientService return yield* courseService.enrollUser(userId, courseId) }) ``` ### Pattern: Composable Operations ```tsx // frontend/src/components/CourseCreator.tsx import { useEffectRunner } from '@/hooks/useEffectRunner' import { ThingClientService } from '@/services/ThingClientService' import { ConnectionClientService } from '@/services/ConnectionClientService' import { Effect } from 'effect' import { useState } from 'react' export function CourseCreator() { const { run, loading, error } = useEffectRunner() const [formData, setFormData] = useState({ title: '', description: '', price: 0 }) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() // Define COMPLEX Effect program with ONLY generic services const program = Effect.gen(function* () { const thingService = yield* ThingClientService const connectionService = yield* ConnectionClientService // 1. Create course (just a thing with type='course') const courseId = yield* thingService.create({ type: 'course', name: formData.title, properties: { title: formData.title, description: formData.description, price: formData.price } }) // 2. Create default lesson (just a thing with type='lesson') const lessonId = yield* thingService.create({ type: 'lesson', name: 'Introduction', properties: { content: 'Welcome to the course!' } }) // 3. Connect lesson to course (generic connection) yield* connectionService.create({ fromThingId: lessonId, toThingId: courseId, relationshipType: 'part_of' }) // 4. Return course ID return courseId }).pipe( // Add retry logic Effect.retry({ times: 3 }), // Add timeout Effect.timeout('5 seconds'), // Handle specific errors Effect.catchTag('ThingCreateError', err => Effect.fail(new Error(`Failed to create course: ${err.reason}`)) ) ) // Run the program const courseId = await run(program, { onSuccess: (id) => { window.location.href = `/courses/${id}` } }) } return ( <form onSubmit={handleSubmit}> <input type="text" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} placeholder="Course Title" /> <textarea value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} placeholder="Description" /> <input type="number" value={formData.price} onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) })} placeholder="Price" /> <button type="submit" disabled={loading}> {loading ? 'Creating...' : 'Create Course'} </button> {error && <Alert variant="destructive">{error}</Alert>} </form> ) } ``` **Key Point:** This handles courses, products, lessons, EVERYTHING with just 2 generic services. No specialized services needed. ### Pattern: Parallel Data Fetching ```tsx // frontend/src/components/CourseDashboard.tsx import { useEffect, useState } from 'react' import { useEffectRunner } from '@/hooks/useEffectRunner' import { ThingClientService } from '@/services/ThingClientService' import { ConnectionClientService } from '@/services/ConnectionClientService' import { Effect } from 'effect' interface Props { courseId: string } export function CourseDashboard({ courseId }: Props) { const { run, loading } = useEffectRunner() const [data, setData] = useState<any>(null) useEffect(() => { // Fetch all data in parallel using GENERIC services const program = Effect.gen(function* () { const thingService = yield* ThingClientService const connectionService = yield* ConnectionClientService // Parallel fetch (all run at once) // Course is just a thing, lessons are connected things const [course, lessons, enrollmentCount] = yield* Effect.all([ thingService.get(courseId), connectionService.getRelated(courseId, 'part_of', 'to'), connectionService.getCount(courseId, 'enrolled_in') ], { concurrency: 'unbounded' }) return { course, lessons, enrollmentCount } }) run(program, { onSuccess: setData }) }, [courseId]) if (loading) return <div>Loading...</div> if (!data) return <div>No data</div> return ( <div> <h1>{data.course.name}</h1> <p>{data.enrollmentCount} students enrolled</p> <h2>Lessons</h2> {data.lessons.map((lesson: any) => ( <div key={lesson._id}>{lesson.name}</div> ))} </div> ) } ``` **This is where a specialized service provides value** - compare: ```tsx // ❌ Without specialized service (3 separate operations) const [course, lessons, enrollmentCount] = yield* Effect.all([ thingService.get(courseId), connectionService.getRelated(courseId, 'part_of', 'to'), connectionService.getCount(courseId, 'enrolled_in') ]) // ✅ With specialized service (cleaner, if you repeat this pattern) const data = yield* courseService.getCourseWithLessons(courseId) const count = yield* courseService.getEnrollmentCount(courseId) ``` But you only add `CourseClientService` **after** you repeat this pattern 3+ times. --- ### Summary: Generic vs Specialized Services ``` ┌─────────────────────────────────────────────────────────┐ │ 66 Thing Types │ │ course, lesson, product, video, post, comment, etc.