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,736 lines (1,457 loc) • 85.5 kB
Markdown
---
title: Ontology Frontend Master
dimension: knowledge
category: ontology-frontend-master.md
tags: 6-dimensions, architecture, backend, frontend, ontology, ui
related_dimensions: events, groups, things
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-master.md category.
Location: one/knowledge/ontology-frontend-master.md
Purpose: Documents frontend development ontology - master reference
Related dimensions: events, groups, things
For AI agents: Read this to understand ontology frontend master.
---
# Frontend Development Ontology - Master Reference
**Version:** 2.1.0
**Type System:** Formal ontology for backend-agnostic Astro website development
**Paradigm:** Pure declarative type theory + Provider pattern + Context engineering
**Master Document:** Unified reference combining architecture, specifications, and patterns
---
## Table of Contents
1. [Overview](#overview)
2. [Core Axioms](#core-axioms)
3. [Architecture Overview](#architecture-overview)
4. [Provider Pattern & Context Engineering](#provider-pattern--context-engineering)
5. [Type Hierarchy](#type-hierarchy)
6. [What Frontend IS and IS NOT](#what-frontend-is-and-is-not)
7. [Frontend File Structure](#frontend-file-structure)
8. [Backend-Agnostic Data Layer](#backend-agnostic-data-layer)
9. [DataProvider Interface](#dataprovider-interface)
10. [Provider Implementations](#provider-implementations)
11. [Configuration](#configuration)
12. [State Management Hierarchy](#state-management-hierarchy)
13. [Effect.ts Service Layer](#effectts-service-layer)
14. [React Hooks & Integration](#react-hooks--integration)
15. [Component Patterns](#component-patterns)
16. [Page Patterns & SSR](#page-patterns--ssr)
17. [Real-Time Components](#real-time-components)
18. [Forms & Data Mutations](#forms--data-mutations)
19. [Multi-Tenant Routing](#multi-tenant-routing)
20. [Caching Ontology](#caching-ontology)
21. [Error Propagation & Handling](#error-propagation--handling)
22. [Testing Patterns](#testing-patterns)
23. [Common Mistakes & Solutions](#common-mistakes--solutions)
24. [Summary & Key Patterns](#summary--key-patterns)
---
## Overview
The frontend is **purely a rendering and interaction layer**. It has:
- ✅ ZERO database access (provider abstracts)
- ✅ ZERO business logic (backend validates)
- ✅ ZERO direct backend coupling (interface-based)
- ✅ 100% backend-agnostic code
**What Frontend Does:**
1. **Renders UI** from data via Astro + React
2. **Calls backend APIs** via abstract DataProvider interface (works with Convex, WordPress, Notion, Supabase, etc.)
3. **Manages local UI state** (loading, errors, forms, modals)
4. **Displays real-time updates** via subscriptions (if backend supports)
**Backend Abstraction:**
```
┌─────────────────────────────────────────┐
│ FRONTEND (Astro + React + Effect.ts) │
│ ✅ Renders UI │
│ ✅ Calls DataProvider interface │
│ ✅ Manages UI state only │
│ ❌ NO database, logic, validation │
└──────────────────┬──────────────────────┘
│ DataProvider Interface
│ (6-dimension ontology)
↓
┌─────────────────────────────────────────┐
│ BACKEND PROVIDERS (Choose One) │
│ ├─ ConvexProvider │
│ ├─ WordPressProvider │
│ ├─ NotionProvider │
│ ├─ SupabaseProvider │
│ └─ CustomProvider │
└─────────────────────────────────────────┘
```
**Key Insight:** The 6-dimension ontology becomes a **universal data API**. Frontend doesn't care if data comes from Convex, WordPress, or Notion—it only knows the interface.
---
## Core Axioms
### Axiom 1: Everything is a Thing
```
∀x ∈ Frontend → x : Thing
Thing ::= Page | Component | Content | Service | Provider | Configuration
```
### Axiom 2: All Things Have Type
```
type : Thing → TypeID
TypeID ::= String ∈ ontology.things.types
```
### Axiom 3: Things Connect
```
connect : Thing × Thing → Connection
Connection ::= { from: Thing, to: Thing, type: RelationType, metadata: Object }
```
### Axiom 4: Actions Emit Events
```
action : Thing → Event
Event ::= { type: EventType, actor: Thing, target: Thing, timestamp: Time, metadata: Object }
```
### Axiom 5: Patterns Compose
```
compose : Pattern × Pattern → Pattern
Pattern ::= { type: PatternType, inputs: [Thing], outputs: [Thing], transform: Function }
```
### Axiom 6: Backend Agnosticism
```
∀Provider. Provider implements DataProviderInterface → Frontend works with Provider
DataProviderInterface ::= { organizations, people, things, connections, events, knowledge }
```
### Axiom 7: Context Minimalism
```
∀Operation. Load only required types, not implementations
ContextUsed << ContextAvailable
target_reduction: 98%+
```
**Key Principle:** Frontend knows 6-dimension ontology, not backend implementation.
---
## Architecture Overview
### Three-Layer Backend-Agnostic Architecture
```
┌──────────────────────────────────────┐
│ RENDERING LAYER (Astro + React) │
│ - SSR pages (.astro files) │
│ - Interactive components (.tsx) │
│ - Astro islands for interactivity │
└──────────────────┬───────────────────┘
│ Effect.ts
↓
┌──────────────────────────────────────┐
│ SERVICE LAYER (Effect.ts) │
│ - ThingService │
│ - ConnectionService │
│ - Type-safe error handling │
│ - Composable operations │
└──────────────────┬───────────────────┘
│ DataProviderInterface
↓
┌──────────────────────────────────────┐
│ DATA LAYER (Providers) │
│ - ConvexProvider (Convex backend) │
│ - WordPressProvider (WordPress) │
│ - NotionProvider (Notion) │
│ - SupabaseProvider (PostgreSQL) │
│ - CustomProvider (Any API) │
└──────────────────────────────────────┘
```
**Why Three Layers?**
- **Rendering Layer:** HTML output (SSR fast, SEO friendly)
- **Service Layer:** Type-safe business operations (Effect.ts)
- **Data Layer:** Backend abstraction (swap backends by changing 1 config line)
**Tech Stack:**
- **Astro 5.14+** - SSR/SSG with file-based routing
- **React 19** - Interactive components with islands architecture
- **Effect.ts** - Type-safe, composable operations
- **DataProvider Pattern** - Backend-agnostic interface
- **Tailwind + shadcn/ui** - Component styling
- **TypeScript 5.9+** - Strict type safety
---
## Provider Pattern & Context Engineering
### The Core Insight: Providers ARE Context Loaders
**Problem:** Traditional frontend development loads massive context to interact with one backend.
**Solution:** Provider pattern = 99.9% context reduction through interface abstraction.
```typescript
{
// ❌ Traditional: Load entire backend
traditionalApproach: {
load: "Full Convex schema + all implementations + all docs",
tokens: 280_000,
problem: "Frontend context explodes with backend knowledge"
}
// ✅ Provider pattern: Load only interface
providerApproach: {
load: "DataProviderInterface only (contract)",
tokens: 300,
benefit: "Frontend never loads backend implementation"
}
result: {
context_reduction: "99.9%",
backend_flexibility: "infinite (swap with config)",
type_safety: "Effect.ts typed errors",
testability: "Mock provider for tests"
}
}
```
### Context Engineering Formula
```typescript
{
traditional: {
formula: "ContextSize = Σ(all_files + all_docs + all_examples)",
typical: "50k-300k tokens per request",
problem: "Hits context limits, slow, expensive"
}
provider: {
formula: "ContextSize = interface_definition + operation_signatures",
typical: "300-500 tokens per request",
benefit: "Never hits limits, fast, cheap, infinite backends"
}
improvement: {
context_reduction: "99%+",
cost_reduction: "100x cheaper",
speed_improvement: "10x faster",
backend_flexibility: "∞ backends supported"
}
}
```
### Provider as Just-In-Time Loader
**Traditional AI code generation:**
```typescript
❌ function generateCourseComponent_Traditional() {
context = {
convexSchema: loadConvexSchema(), // 15,000 tokens
convexMutations: loadMutations(), // 20,000 tokens
convexQueries: loadQueries(), // 18,000 tokens
implementations: loadImplementations() // 50,000 tokens
} // Total: 103,000 tokens
return ai.generate(task, context)
}
```
**Provider pattern:**
```typescript
✅ function generateCourseComponent_Provider() {
context = {
interface: "DataProviderInterface", // 300 tokens
operations: ["things.get", "things.list"] // Which operations needed
} // Total: 300 tokens (99.7% reduction)
// AI generates code using interface
code = ai.generate(task, context)
// Result: provider.things.get(id) - works with ANY backend
return code
}
```
---
## Type Hierarchy
### The Complete Frontend Type System
```
Thing
├── Artifact // Code artifacts (what agents create)
│ ├── Page
│ │ ├── LandingPage
│ │ ├── BlogIndex
│ │ ├── BlogPost
│ │ ├── AppPage
│ │ ├── AccountPage
│ │ └── APIRoute
│ ├── Component
│ │ ├── UIComponent
│ │ ├── FeatureComponent
│ │ ├── Layout
│ │ └── Island
│ ├── Content
│ │ ├── Collection
│ │ ├── Entry
│ │ └── Schema
│ ├── Service
│ │ ├── GenericService // Handles all 66 types
│ │ └── SpecializedService // Optional convenience
│ ├── Provider
│ │ ├── ConvexProvider
│ │ ├── WordPressProvider
│ │ ├── NotionProvider
│ │ ├── SupabaseProvider
│ │ └── CustomProvider
│ └── Configuration
│ ├── DisplayConfig // UI labels, icons, colors
│ └── ProviderConfig // Backend URL, API keys
├── Pattern // Reusable templates
├── Interface // Abstract contracts
└── Capability // Agent capabilities
```
---
## 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)
const [formData, setFormData] = useState({})
```
**4. User Interactions**
```tsx
<button onClick={handleClick}>Create Course</button>
<input onChange={handleChange} />
```
**5. Client-Side Routing**
```tsx
<Link href="/courses">View Courses</Link>
```
**6. Component-Local UI Logic**
```typescript
type FrontendResponsibility =
| { type: "render"; data: any } // Display UI from data
| { type: "call_provider"; operation: any } // Call DataProvider (not direct backend)
| { type: "manage_ui_state"; state: any } // Form inputs, modals, loading
| { type: "route"; path: string } // Client-side navigation
| { type: "cache_display"; data: any }; // Cache UI data (not business data)
```
### ❌ 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 (UX hint only, backend MUST validate)
if (formData.email.includes('@')) {
// Backend MUST validate, not frontend
}
```
**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 (UI hint only, backend MUST authorize)
if (user.role === 'admin') {
// Backend MUST authorize, frontend only for UI hints
}
```
**6. Backend-Specific Code**
```typescript
type FrontendProhibition =
| { type: "database_access" }
| { type: "business_logic" }
| { type: "event_logging" }
| { type: "authorization_enforcement" }
| { type: "data_validation_enforcement" }
| { type: "org_filtering" }
| { type: "backend_specific_code" };
```
---
## 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: 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)
│ ├── providers/ # Backend provider implementations
│ │ ├── DataProvider.ts # Interface definition
│ │ ├── convex/
│ │ │ └── ConvexProvider.ts
│ │ ├── wordpress/
│ │ │ └── WordPressProvider.ts
│ │ ├── notion/
│ │ │ └── NotionProvider.ts
│ │ └── supabase/
│ │ └── SupabaseProvider.ts
│ ├── lib/
│ │ └── convex.ts # Backend client setup
│ ├── middleware.ts # Multi-tenant routing
│ └── styles/ # Tailwind + CSS
├── .env.local
│ PUBLIC_CONVEX_URL=https://backend.convex.cloud
└── package.json
```
**Key Point:** Frontend has NO `convex/` directory. Backend-specific code lives in provider implementations only.
---
## Backend-Agnostic Data Layer
**Inspired by Astro's Content Layer Pattern**
Astro elegantly abstracts content sources via a universal interface. 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. The provider interface is the universal contract.
---
## DataProvider Interface
**The universal ontology API that all backends must implement.**
```typescript
// 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[]
permissions?: string[]
image?: string
bio?: string
createdAt: number
updatedAt: number
}
export interface Thing {
_id: string
type: ThingType
name: string
groupId?: string
properties: Record<string, any>
status: 'draft' | 'active' | 'archived'
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
targetId?: string
groupId: string
metadata?: Record<string, any>
}) => Effect.Effect<void, Error>
query: (params: {
type?: EventType
actorId?: string
targetId?: string
groupId?: string
from?: Date
to?: Date
}) => Effect.Effect<Event[], Error>
}
// Dimension 6: Knowledge operations
knowledge: {
embed: (params: {
text: string
sourceThingId?: string
sourcePersonId?: string
groupId: string
labels?: string[]
}) => Effect.Effect<string, Error>
search: (params: {
query: string
groupId?: string
limit?: number
}) => Effect.Effect<KnowledgeMatch[], Error>
}
// Optional: Real-time subscriptions
subscriptions?: {
watchThing: (id: string) => Effect.Effect<Observable<Thing>, Error>
watchList: (type: ThingType, groupId?: string) => Effect.Effect<Observable<Thing[]>, Error>
}
}
```
**Provider Algebra:**
```typescript
// Provider composition
implement : Backend × DataProviderInterface → Provider
// Examples:
ConvexProvider = implement(ConvexBackend, DataProviderInterface)
WordPressProvider = implement(WordPressBackend, DataProviderInterface)
NotionProvider = implement(NotionBackend, DataProviderInterface)
// Swapping backends (change ONE line in config)
config.provider = ConvexProvider({ url: "..." })
// OR
config.provider = WordPressProvider({ url: "...", apiKey: "..." })
// OR
config.provider = NotionProvider({ apiKey: "...", databaseId: "..." })
// Result: Frontend components don't change
```
---
## 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 Error(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* () {
const query = new URLSearchParams({
per_page: String(params.limit || 10)
})
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))
})
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* () {
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 Error(String(error))
})
const post = yield* Effect.tryPromise({
try: () => response.json(),
catch: (error) => new Error(String(error))
})
return post.id.toString()
}),
update: (id, updates) => { /* Similar WordPress update */ },
delete: (id) => { /* Similar WordPress delete */ }
}
connections = { /* WordPress relationships via post meta or custom tables */ }
events = { /* Log to WordPress activity log */ }
knowledge = { /* Elasticsearch, Algolia, or 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* () {
const page = yield* Effect.tryPromise({
try: () => this.notion.pages.retrieve({ page_id: id }),
catch: (error) => new Error(String(error))
})
return {
_id: page.id,
type: 'document' as ThingType,
name: (page.properties.Name as any).title[0]?.plain_text || '',
properties: {},
status: 'active',
createdAt: new Date(page.created_time).getTime(),
updatedAt: new Date(page.last_edited_time).getTime()
}
}),
list: (params) =>
Effect.gen(function* () {
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))
})
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* () {
const page = yield* Effect.tryPromise({
try: () =>
this.notion.pages.create({
parent: { database_id: this.databaseId },
properties: {
Name: { title: [{ text: { content: input.name } }] }
}
}),
catch: (error) => new Error(String(error))
})
return page.id
}),
update: (id, updates) => { /* Similar Notion update */ },
delete: (id) => { /* Notion archive page */ }
}
connections = { /* Notion relations map to connections */ }
events = { /* Log to separate Notion database */ }
knowledge = { /* 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* () {
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 Error(String(err))
})
if (error) {
return yield* Effect.fail(new Error(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* () {
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. Frontend stays the same.
---
## Configuration
### Astro-Style Backend Swapping
**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
### Display Configuration (UI 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',
color: 'green',
// Which fields to show in UI
primaryField: 'title',
secondaryField: 'description',
imageField: 'thumbnail',
// Form fields (frontend 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)
---
## State Management Hierarchy
### Four-Layer State Architecture
```typescript
{
// Level 1: Server State (Provider owns - SOURCE OF TRUTH)
ServerState: {
owner: "Provider (backed by database)",
source: "Backend database",
access: "Query/Mutation via provider.interface only",
examples: [
"course list",
"user profile",
"enrollment count",
"lesson progress"
],
caching: "Provider-level (Cloudflare KV, Edge cache)",
lifetime: "Persistent (until backend changes)",
rule: "NEVER duplicate in frontend state. Query when needed."
},
// Level 2: SSR State (Astro owns - REQUEST SCOPED)
SSRState: {
owner: "Astro page (.astro files)",
source: "Server-side provider fetch",
access: "Props passed to components",
examples: [
"Initial page data (course details)",
"SEO metadata (title, description)",
"Organization context (from subdomain)"
],
lifetime: "One HTTP request (not persisted)",
pattern: `
---
const course = await provider.things.get(id)
---
<Layout title={course.name}>
<CourseDetail course={course} />
</Layout>
`,
rule: "Use for SEO, initial render, server-only data"
},
// Level 3: Island State (React owns - COMPONENT SCOPED)
IslandState: {
owner: "React component (useState/useReducer)",
source: "Component-local logic",
access: "Component-private (not shared)",
examples: [
"Form input values",
"Modal open/closed",
"Dropdown expanded",
"Loading spinner visible"
],
lifetime: "Component mounted (unmount = state destroyed)",
pattern: `
export function CourseForm() {
const [title, setTitle] = useState('')
const [loading, setLoading] = useState(false)
// State dies when component unmounts
}
`,
rule: "UI-only state. Never duplicate server state."
},
// Level 4: Shared Client State (Nanostores owns - SESSION SCOPED)
SharedState: {
owner: "Nanostores (global atoms)",
source: "Cross-component coordination",
access: "Multiple components subscribe",
examples: [
"Sidebar expanded/collapsed",
"Dark/light theme preference",
"User locale (en/es/fr)",
"Toast notifications"
],
lifetime: "Browser session + localStorage",
pattern: `
// stores/layout.ts
export const sidebarExpanded = atom(true)
// Component A
const expanded = useStore(sidebarExpanded)
// Component B (synced with A)
const expanded = useStore(sidebarExpanded)
`,
rule: "UI preferences only. NOT server data."
},
// Anti-Pattern: Duplicating Server State
antiPattern: {
problem: "Storing server data in frontend state",
bad: `
❌ const [courses, setCourses] = useState([])
❌ useEffect(() => {
provider.things.list('course').then(setCourses)
}, [])
// Problem: Server state duplicated in frontend
// Problem: Stale data, manual sync, cache invalidation
`,
solution: `
✅ const courses = useQuery(api.courses.list)
// Provider handles caching, revalidation, subscriptions
// Frontend just renders. No state duplication.
`
},
// Decision Tree
decisionTree: {
question: "Does this state persist after page reload?",
yes: {
question: "Is this data from backend?",
yes: "ServerState (provider query)",
no: "SharedState (nanostores + localStorage)"
},
no: {
question: "Do multiple components need this state?",
yes: "SharedState (nanostores, session-only)",
no: {
question: "Is this SSR data?",
yes: "SSRState (Astro props)",
no: "IslandState (useState)"
}
}
}
}
```
---
## Effect.ts Service Layer
**Effect.ts services consume the DataProvider interface.**
### 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
### 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
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 3+ times.
### Generic Service Pattern
```typescript
// frontend/src/services/ThingService.ts
import { Effect } from 'effect'
import { DataProvider } from '@/providers/DataProvider'
export class ThingService extends Effect.Service<ThingService>()(
'ThingService',
{
effect: Effect.gen(function* () {
const provider = yield* DataProvider
return {
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]
}
) {}
```
**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.
### Specialized Service Pattern (Optional)
**Only add if you repeat the same multi-step operation 3+ times:**
```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
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
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)
### Dependency Injection Layer
```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 Hooks & Integration
### useEffectRunner Hook
Custom hook for running Effect programs in React components.
```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'
})
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>
)
}
```
### 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