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
Markdown
---
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.