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,721 lines (1,437 loc) 46.9 kB
--- title: Any Backend dimension: connections category: any-backend.md tags: ai, architecture, backend, convex, frontend related_dimensions: events scope: global created: 2025-11-03 updated: 2025-11-03 version: 1.0.0 ai_context: | This document is part of the connections dimension in the any-backend.md category. Location: one/connections/any-backend.md Purpose: Documents connecting one to any backend Related dimensions: events For AI agents: Read this to understand any backend. --- # Connecting ONE to Any Backend **Universal backend adapter pattern for ONE platform** --- ## Executive Summary ONE's frontend is **100% backend-agnostic**. This guide shows how to connect to **any backend** (databases, APIs, CMS platforms, SaaS tools) by implementing the universal `DataProvider` interface. **Key Principle:** Frontend talks to `DataProvider` interface. You implement `DataProvider` for your backend. Done. **Supported Backends:** - **Databases**: Convex, Supabase, Neon, PlanetScale, MongoDB, PostgreSQL, MySQL - **CMS**: WordPress, Strapi, Contentful, Sanity, Ghost, Prismic - **Headless**: Notion, Airtable, Google Sheets, Excel Online - **SaaS APIs**: Salesforce, HubSpot, Shopify, Stripe - **Custom**: Your own REST/GraphQL API **What This Achieves:** - Change backend with ONE line of code - Use multiple backends simultaneously - Mix backends (WordPress + Supabase + Custom API) - No vendor lock-in --- ## Table of Contents 1. [Architecture Overview](#architecture-overview) 2. [DataProvider Interface](#dataprovider-interface) 3. [Implementation Patterns](#implementation-patterns) 4. [Database Backends](#database-backends) 5. [CMS Backends](#cms-backends) 6. [SaaS API Backends](#saas-api-backends) 7. [Custom Backends](#custom-backends) 8. [Multi-Backend Setup](#multi-backend-setup) 9. [Testing](#testing) 10. [Deployment](#deployment) --- ## Architecture Overview ### The Universal Pattern ``` ┌────────────────────────────────────────────────┐ ONE Frontend (Astro + React) Renders UI Calls DataProvider interface Backend-agnostic components NO backend-specific code └────────────────┬───────────────────────────────┘ DataProvider Interface (Universal API = ONE Ontology) ┌────────────────────────────────────────────────┐ Provider Layer (You Implement) - things.get(id) - things.list(type) - things.create(data) - connections.create(from, to) - events.log(event) - knowledge.search(query) └────────────────┬───────────────────────────────┘ Backend-Specific Implementation ┌───────────┼───────────┬───────────┬───────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ Convex │Supabase │WordPress Notion Custom API └─────────┘ └─────────┘ └──────────┘ └──────────┘ └──────────┘ ``` **Key Insight:** Frontend never imports backend-specific code. Only imports `DataProvider` interface. --- ## DataProvider Interface ### The Contract ```typescript // frontend/src/providers/DataProvider.ts import { Effect } from 'effect' import { ThingType, ConnectionType, EventType } from '@/ontology/types' // Error types (universal) export class ThingNotFoundError { readonly _tag = 'ThingNotFoundError' constructor(readonly thingId: string) {} } export class UnauthorizedError { readonly _tag = 'UnauthorizedError' } export class ConnectionCreateError { readonly _tag = 'ConnectionCreateError' constructor(readonly reason: string) {} } // Thing (universal data structure) export interface Thing { _id: string type: ThingType name: string properties: Record<string, any> status: 'active' | 'inactive' | 'archived' createdAt: number updatedAt: number createdBy?: string groupId?: string } // Connection (universal relationship) export interface Connection { _id: string fromThingId: string toThingId: string relationshipType: ConnectionType metadata?: Record<string, any> createdAt: number } // Event (universal activity) export interface Event { _id: string type: EventType actorId: string targetId?: string metadata?: Record<string, any> timestamp: number } // Knowledge (universal search result) export interface KnowledgeMatch { thingId: string score: number text: string metadata?: Record<string, any> } // ============================================ // DataProvider Interface // ============================================ export interface DataProvider { // Things operations things: { get: (id: string) => Effect.Effect<Thing, ThingNotFoundError | UnauthorizedError> list: (params: { type: ThingType groupId?: string filters?: Record<string, any> limit?: number offset?: number }) => Effect.Effect<Thing[], Error> create: (input: { type: ThingType name: string properties: Record<string, any> groupId?: string }) => Effect.Effect<string, ConnectionCreateError> update: ( id: string, updates: Partial<Thing> ) => Effect.Effect<void, Error> delete: (id: string) => Effect.Effect<void, Error> } // 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> } // Events operations events: { log: (event: { type: EventType actorId: string targetId?: string metadata?: Record<string, any> }) => Effect.Effect<void, Error> query: (params: { type?: EventType actorId?: string targetId?: string from?: Date to?: Date }) => Effect.Effect<Event[], Error> } // Knowledge operations knowledge: { embed: (params: { text: string sourceThingId: string labels?: string[] }) => Effect.Effect<string, Error> search: ( query: string, limit?: number ) => Effect.Effect<KnowledgeMatch[], Error> } // Optional: Real-time subscriptions subscriptions?: { watchThing: (id: string) => Effect.Effect<Observable<Thing>, Error> watchList: (type: ThingType) => Effect.Effect<Observable<Thing[]>, Error> } } ``` **That's it!** Implement this interface for ANY backend. --- ## Implementation Patterns ### Pattern 1: REST API Backend ```typescript // frontend/src/providers/rest/RestProvider.ts import { Effect, Layer } from 'effect' import { DataProvider, Thing, ThingNotFoundError } from '../DataProvider' export class RestProvider implements DataProvider { constructor( private baseUrl: string, private apiKey: string ) {} things = { get: (id: string) => Effect.gen(this, function* () { const response = yield* Effect.tryPromise({ try: () => fetch(`${this.baseUrl}/things/${id}`, { headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' } }), catch: (error) => new Error(String(error)) }) if (!response.ok) { if (response.status === 404) { return yield* Effect.fail(new ThingNotFoundError(id)) } return yield* Effect.fail(new Error(`HTTP ${response.status}`)) } const data = yield* Effect.tryPromise({ try: () => response.json(), catch: (error) => new Error(String(error)) }) // Transform API response ONE Thing return this.transformToThing(data) }), list: (params) => Effect.gen(this, function* () { const query = new URLSearchParams({ type: params.type, limit: String(params.limit || 10), ...(params.groupId && { org: params.groupId }) }) const response = yield* Effect.tryPromise({ try: () => fetch(`${this.baseUrl}/things?${query}`, { headers: { 'Authorization': `Bearer ${this.apiKey}` } }), catch: (error) => new Error(String(error)) }) const data = yield* Effect.tryPromise({ try: () => response.json(), catch: (error) => new Error(String(error)) }) return data.items.map((item: any) => this.transformToThing(item)) }), create: (input) => Effect.gen(this, function* () { const response = yield* Effect.tryPromise({ try: () => fetch(`${this.baseUrl}/things`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ type: input.type, name: input.name, properties: input.properties, groupId: input.groupId }) }), catch: (error) => new Error(String(error)) }) const data = yield* Effect.tryPromise({ try: () => response.json(), catch: (error) => new Error(String(error)) }) return data.id }), update: (id, updates) => Effect.gen(this, function* () { yield* Effect.tryPromise({ try: () => fetch(`${this.baseUrl}/things/${id}`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }), catch: (error) => new Error(String(error)) }) }), delete: (id) => Effect.gen(this, function* () { yield* Effect.tryPromise({ try: () => fetch(`${this.baseUrl}/things/${id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${this.apiKey}` } }), catch: (error) => new Error(String(error)) }) }) } connections = { // Similar REST API implementations create: (input) => { /* POST /connections */ }, getRelated: (params) => { /* GET /things/:id/related */ }, getCount: (thingId, type) => { /* GET /things/:id/connections/count */ }, delete: (id) => { /* DELETE /connections/:id */ } } events = { log: (event) => { /* POST /events */ }, query: (params) => { /* GET /events */ } } knowledge = { embed: (params) => { /* POST /knowledge/embed */ }, search: (query, limit) => { /* GET /knowledge/search */ } } // Helper: Transform API data ONE Thing private transformToThing(data: any): Thing { return { _id: data.id, type: data.type, name: data.name || data.title, properties: data.attributes || data.properties || {}, status: data.status || 'active', createdAt: new Date(data.created_at).getTime(), updatedAt: new Date(data.updated_at).getTime(), createdBy: data.created_by, groupId: data.organization_id } } } // Factory function export function restProvider(config: { url: string; apiKey: string }) { return Layer.succeed(DataProvider, new RestProvider(config.url, config.apiKey)) } ``` ### Pattern 2: GraphQL Backend ```typescript // frontend/src/providers/graphql/GraphQLProvider.ts import { Effect, Layer } from 'effect' import { DataProvider, Thing } from '../DataProvider' export class GraphQLProvider implements DataProvider { constructor( private endpoint: string, private headers: Record<string, string> ) {} things = { get: (id: string) => Effect.gen(this, function* () { const query = ` query GetThing($id: ID!) { thing(id: $id) { id type name properties status createdAt updatedAt } } ` const response = yield* Effect.tryPromise({ try: () => fetch(this.endpoint, { method: 'POST', headers: { ...this.headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables: { id } }) }), catch: (error) => new Error(String(error)) }) const data = yield* Effect.tryPromise({ try: () => response.json(), catch: (error) => new Error(String(error)) }) if (data.errors) { return yield* Effect.fail(new Error(data.errors[0].message)) } return this.transformToThing(data.data.thing) }), list: (params) => Effect.gen(this, function* () { const query = ` query ListThings($type: String!, $limit: Int, $orgId: ID) { things(type: $type, limit: $limit, groupId: $orgId) { nodes { id type name properties status createdAt updatedAt } } } ` const response = yield* Effect.tryPromise({ try: () => fetch(this.endpoint, { method: 'POST', headers: { ...this.headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables: { type: params.type, limit: params.limit || 10, orgId: params.groupId } }) }), catch: (error) => new Error(String(error)) }) const data = yield* Effect.tryPromise({ try: () => response.json(), catch: (error) => new Error(String(error)) }) return data.data.things.nodes.map((node: any) => this.transformToThing(node) ) }), create: (input) => Effect.gen(this, function* () { const mutation = ` mutation CreateThing($input: CreateThingInput!) { createThing(input: $input) { id } } ` const response = yield* Effect.tryPromise({ try: () => fetch(this.endpoint, { method: 'POST', headers: { ...this.headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: mutation, variables: { input: { type: input.type, name: input.name, properties: input.properties, groupId: input.groupId } } }) }), catch: (error) => new Error(String(error)) }) const data = yield* Effect.tryPromise({ try: () => response.json(), catch: (error) => new Error(String(error)) }) return data.data.createThing.id }), update: (id, updates) => { /* Similar mutation */ }, delete: (id) => { /* Similar mutation */ } } // connections, events, knowledge... private transformToThing(data: any): Thing { return { _id: data.id, type: data.type, name: data.name, properties: JSON.parse(data.properties || '{}'), status: data.status, createdAt: new Date(data.createdAt).getTime(), updatedAt: new Date(data.updatedAt).getTime() } } } // Factory export function graphqlProvider(config: { endpoint: string headers: Record<string, string> }) { return Layer.succeed( DataProvider, new GraphQLProvider(config.endpoint, config.headers) ) } ``` ### Pattern 3: SQL Database (Direct) ```typescript // frontend/src/providers/postgres/PostgresProvider.ts import { Effect, Layer } from 'effect' import { Pool } from 'pg' import { DataProvider, Thing } from '../DataProvider' export class PostgresProvider implements DataProvider { private pool: Pool constructor(connectionString: string) { this.pool = new Pool({ connectionString }) } things = { get: (id: string) => Effect.gen(this, function* () { const result = yield* Effect.tryPromise({ try: () => this.pool.query('SELECT * FROM things WHERE id = $1', [id]), catch: (error) => new Error(String(error)) }) if (result.rows.length === 0) { return yield* Effect.fail(new ThingNotFoundError(id)) } return this.rowToThing(result.rows[0]) }), list: (params) => Effect.gen(this, function* () { const query = ` SELECT * FROM things WHERE type = $1 ${params.groupId ? 'AND organization_id = $2' : ''} LIMIT $${params.groupId ? '3' : '2'} ` const values = [ params.type, ...(params.groupId ? [params.groupId] : []), params.limit || 10 ] const result = yield* Effect.tryPromise({ try: () => this.pool.query(query, values), catch: (error) => new Error(String(error)) }) return result.rows.map(row => this.rowToThing(row)) }), create: (input) => Effect.gen(this, function* () { const result = yield* Effect.tryPromise({ try: () => this.pool.query( `INSERT INTO things (type, name, properties, organization_id, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) RETURNING id`, [ input.type, input.name, JSON.stringify(input.properties), input.groupId, 'active' ] ), catch: (error) => new Error(String(error)) }) return result.rows[0].id }), update: (id, updates) => { /* UPDATE query */ }, delete: (id) => { /* DELETE query */ } } connections = { create: (input) => Effect.gen(this, function* () { const result = yield* Effect.tryPromise({ try: () => this.pool.query( `INSERT INTO connections (from_thing_id, to_thing_id, relationship_type, metadata, created_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING id`, [ input.fromThingId, input.toThingId, input.relationshipType, JSON.stringify(input.metadata || {}) ] ), catch: (error) => new Error(String(error)) }) return result.rows[0].id }), getRelated: (params) => { /* JOIN query */ }, getCount: (thingId, type) => { /* COUNT query */ }, delete: (id) => { /* DELETE query */ } } events = { log: (event) => { /* INSERT INTO events */ }, query: (params) => { /* SELECT FROM events */ } } knowledge = { embed: (params) => { /* Store embedding, use pgvector */ }, search: (query, limit) => { /* Vector similarity search */ } } private rowToThing(row: any): Thing { return { _id: row.id, type: row.type, name: row.name, properties: JSON.parse(row.properties || '{}'), status: row.status, createdAt: new Date(row.created_at).getTime(), updatedAt: new Date(row.updated_at).getTime(), groupId: row.organization_id } } } // Factory export function postgresProvider(config: { connectionString: string }) { return Layer.succeed( DataProvider, new PostgresProvider(config.connectionString) ) } ``` --- ## Database Backends ### Supabase ```typescript // frontend/src/providers/supabase/SupabaseProvider.ts import { createClient } from '@supabase/supabase-js' import { Effect, Layer } from 'effect' import { DataProvider } from '../DataProvider' export class SupabaseProvider implements DataProvider { private supabase constructor(url: string, anonKey: string) { this.supabase = createClient(url, anonKey) } things = { get: (id: string) => Effect.gen(this, 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 this.transformToThing(data) }), list: (params) => Effect.gen(this, function* () { let query = this.supabase .from('things') .select('*') .eq('type', params.type) if (params.groupId) { query = query.eq('organization_id', params.groupId) } const { data, error } = yield* Effect.tryPromise({ try: () => query.limit(params.limit || 10), catch: (err) => new Error(String(err)) }) if (error) { return yield* Effect.fail(new Error(error.message)) } return data.map(row => this.transformToThing(row)) }), // create, update, delete... } // Supabase supports real-time! subscriptions = { watchThing: (id: string) => Effect.gen(this, function* () { // Supabase real-time subscription const channel = this.supabase .channel(`thing:${id}`) .on('postgres_changes', { event: '*', schema: 'public', table: 'things', filter: `id=eq.${id}` }, (payload) => { // Emit updates via Observable }) yield* Effect.tryPromise({ try: () => channel.subscribe(), catch: (err) => new Error(String(err)) }) // Return observable }) } // ... rest of implementation } export function supabaseProvider(config: { url: string; anonKey: string }) { return Layer.succeed( DataProvider, new SupabaseProvider(config.url, config.anonKey) ) } ``` ### Neon (Serverless Postgres) ```typescript // frontend/src/providers/neon/NeonProvider.ts import { neon } from '@neondatabase/serverless' import { Effect, Layer } from 'effect' import { DataProvider } from '../DataProvider' export class NeonProvider implements DataProvider { private sql constructor(connectionString: string) { this.sql = neon(connectionString) } things = { get: (id: string) => Effect.gen(this, function* () { const rows = yield* Effect.tryPromise({ try: () => this.sql`SELECT * FROM things WHERE id = ${id}`, catch: (error) => new Error(String(error)) }) if (rows.length === 0) { return yield* Effect.fail(new ThingNotFoundError(id)) } return this.transformToThing(rows[0]) }), list: (params) => Effect.gen(this, function* () { const rows = yield* Effect.tryPromise({ try: () => this.sql` SELECT * FROM things WHERE type = ${params.type} ${params.groupId ? this.sql`AND organization_id = ${params.groupId}` : this.sql``} LIMIT ${params.limit || 10} `, catch: (error) => new Error(String(error)) }) return rows.map(row => this.transformToThing(row)) }), // create, update, delete... } // ... rest of implementation } export function neonProvider(config: { connectionString: string }) { return Layer.succeed(DataProvider, new NeonProvider(config.connectionString)) } ``` ### PlanetScale (MySQL) ```typescript // frontend/src/providers/planetscale/PlanetScaleProvider.ts import { connect } from '@planetscale/database' import { Effect, Layer } from 'effect' import { DataProvider } from '../DataProvider' export class PlanetScaleProvider implements DataProvider { private db constructor(config: { host: string; username: string; password: string }) { this.db = connect({ host: config.host, username: config.username, password: config.password }) } things = { get: (id: string) => Effect.gen(this, function* () { const result = yield* Effect.tryPromise({ try: () => this.db.execute('SELECT * FROM things WHERE id = ?', [id]), catch: (error) => new Error(String(error)) }) if (result.rows.length === 0) { return yield* Effect.fail(new ThingNotFoundError(id)) } return this.transformToThing(result.rows[0]) }), // ... rest of implementation } } export function planetscaleProvider(config: { host: string username: string password: string }) { return Layer.succeed( DataProvider, new PlanetScaleProvider(config) ) } ``` ### MongoDB ```typescript // frontend/src/providers/mongo/MongoProvider.ts import { MongoClient } from 'mongodb' import { Effect, Layer } from 'effect' import { DataProvider } from '../DataProvider' export class MongoProvider implements DataProvider { private client: MongoClient private db constructor(connectionString: string, dbName: string) { this.client = new MongoClient(connectionString) this.db = this.client.db(dbName) } things = { get: (id: string) => Effect.gen(this, function* () { const thing = yield* Effect.tryPromise({ try: () => this.db.collection('things').findOne({ _id: id }), catch: (error) => new Error(String(error)) }) if (!thing) { return yield* Effect.fail(new ThingNotFoundError(id)) } return thing as Thing }), list: (params) => Effect.gen(this, function* () { const filter: any = { type: params.type } if (params.groupId) { filter.groupId = params.groupId } const things = yield* Effect.tryPromise({ try: () => this.db .collection('things') .find(filter) .limit(params.limit || 10) .toArray(), catch: (error) => new Error(String(error)) }) return things as Thing[] }), create: (input) => Effect.gen(this, function* () { const result = yield* Effect.tryPromise({ try: () => this.db.collection('things').insertOne({ type: input.type, name: input.name, properties: input.properties, groupId: input.groupId, status: 'active', createdAt: Date.now(), updatedAt: Date.now() }), catch: (error) => new Error(String(error)) }) return result.insertedId.toString() }), // update, delete... } // connections, events, knowledge... } export function mongoProvider(config: { connectionString: string dbName: string }) { return Layer.succeed( DataProvider, new MongoProvider(config.connectionString, config.dbName) ) } ``` --- ## CMS Backends See detailed guides: - **wordpress.md** - WordPress + WooCommerce backend - **cms.md** - Strapi, Contentful, Sanity, Ghost, Prismic Quick examples: ### Strapi ```typescript // frontend/src/providers/strapi/StrapiProvider.ts import { Effect, Layer } from 'effect' import { DataProvider } from '../DataProvider' export class StrapiProvider implements DataProvider { constructor(private baseUrl: string, private apiKey: string) {} things = { get: (id: string) => Effect.gen(this, function* () { const response = yield* Effect.tryPromise({ try: () => fetch(`${this.baseUrl}/api/${this.getCollectionName(type)}/${id}`, { headers: { 'Authorization': `Bearer ${this.apiKey}` } }), catch: (error) => new Error(String(error)) }) const data = yield* Effect.tryPromise({ try: () => response.json(), catch: (error) => new Error(String(error)) }) // Transform Strapi data ONE Thing return { _id: data.data.id.toString(), type: data.data.attributes.__component, name: data.data.attributes.title || data.data.attributes.name, properties: data.data.attributes, status: data.data.attributes.publishedAt ? 'active' : 'inactive', createdAt: new Date(data.data.attributes.createdAt).getTime(), updatedAt: new Date(data.data.attributes.updatedAt).getTime() } }), // ... rest } private getCollectionName(type: string): string { // Map ONE types Strapi collections const mapping = { 'post': 'articles', 'course': 'courses', 'product': 'products' } return mapping[type] || type + 's' } } export function strapiProvider(config: { url: string; apiKey: string }) { return Layer.succeed(DataProvider, new StrapiProvider(config.url, config.apiKey)) } ``` ### Contentful ```typescript // frontend/src/providers/contentful/ContentfulProvider.ts import { createClient } from 'contentful' import { Effect, Layer } from 'effect' import { DataProvider } from '../DataProvider' export class ContentfulProvider implements DataProvider { private client constructor(spaceId: string, accessToken: string) { this.client = createClient({ space: spaceId, accessToken: accessToken }) } things = { get: (id: string) => Effect.gen(this, function* () { const entry = yield* Effect.tryPromise({ try: () => this.client.getEntry(id), catch: (error) => new Error(String(error)) }) return { _id: entry.sys.id, type: entry.sys.contentType.sys.id, name: entry.fields.title || entry.fields.name, properties: entry.fields, status: 'active', createdAt: new Date(entry.sys.createdAt).getTime(), updatedAt: new Date(entry.sys.updatedAt).getTime() } }), list: (params) => Effect.gen(this, function* () { const entries = yield* Effect.tryPromise({ try: () => this.client.getEntries({ content_type: params.type, limit: params.limit || 10 }), catch: (error) => new Error(String(error)) }) return entries.items.map(entry => ({ _id: entry.sys.id, type: entry.sys.contentType.sys.id, name: entry.fields.title || entry.fields.name, properties: entry.fields, status: 'active', createdAt: new Date(entry.sys.createdAt).getTime(), updatedAt: new Date(entry.sys.updatedAt).getTime() })) }), // Contentful is read-only via API (use Management API for writes) create: () => Effect.fail(new Error('Use Contentful Management API')), update: () => Effect.fail(new Error('Use Contentful Management API')), delete: () => Effect.fail(new Error('Use Contentful Management API')) } // ... rest } export function contentfulProvider(config: { spaceId: string accessToken: string }) { return Layer.succeed( DataProvider, new ContentfulProvider(config.spaceId, config.accessToken) ) } ``` --- ## SaaS API Backends ### Notion ```typescript // frontend/src/providers/notion/NotionProvider.ts import { Client } from '@notionhq/client' import { Effect, Layer } from 'effect' 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(this, 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', name: (page.properties.Name as any).title[0]?.plain_text || '', properties: page.properties, status: 'active', createdAt: new Date(page.created_time).getTime(), updatedAt: new Date(page.last_edited_time).getTime() } }), list: (params) => Effect.gen(this, 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: page.properties, status: 'active', createdAt: new Date(page.created_time).getTime(), updatedAt: new Date(page.last_edited_time).getTime() })) }), create: (input) => Effect.gen(this, 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, delete... } // ... rest } export function notionProvider(config: { apiKey: string; databaseId: string }) { return Layer.succeed( DataProvider, new NotionProvider(config.apiKey, config.databaseId) ) } ``` ### Airtable ```typescript // frontend/src/providers/airtable/AirtableProvider.ts import Airtable from 'airtable' import { Effect, Layer } from 'effect' import { DataProvider } from '../DataProvider' export class AirtableProvider implements DataProvider { private base constructor(apiKey: string, baseId: string) { const airtable = new Airtable({ apiKey }) this.base = airtable.base(baseId) } things = { get: (id: string) => Effect.gen(this, function* () { const record = yield* Effect.tryPromise({ try: () => this.base('Things').find(id), catch: (error) => new Error(String(error)) }) return { _id: record.id, type: record.fields.Type, name: record.fields.Name, properties: record.fields, status: 'active', createdAt: new Date(record.fields.Created).getTime(), updatedAt: new Date(record.fields.Modified).getTime() } }), list: (params) => Effect.gen(this, function* () { const records = yield* Effect.tryPromise({ try: () => this.base('Things') .select({ filterByFormula: `{Type} = '${params.type}'`, maxRecords: params.limit || 10 }) .all(), catch: (error) => new Error(String(error)) }) return records.map(record => ({ _id: record.id, type: record.fields.Type, name: record.fields.Name, properties: record.fields, status: 'active', createdAt: new Date(record.fields.Created).getTime(), updatedAt: new Date(record.fields.Modified).getTime() })) }), // create, update, delete... } // ... rest } export function airtableProvider(config: { apiKey: string; baseId: string }) { return Layer.succeed( DataProvider, new AirtableProvider(config.apiKey, config.baseId) ) } ``` --- ## Custom Backends ### Your Own API ```typescript // frontend/src/providers/custom/CustomProvider.ts import { Effect, Layer } from 'effect' import { DataProvider } from '../DataProvider' export class CustomProvider implements DataProvider { constructor( private config: { baseUrl: string authToken: string // Add your custom config } ) {} things = { get: (id: string) => Effect.gen(this, function* () { // Your API call const response = yield* Effect.tryPromise({ try: () => fetch(`${this.config.baseUrl}/v1/entities/${id}`, { headers: { 'X-API-Key': this.config.authToken } }), catch: (error) => new Error(String(error)) }) const data = yield* Effect.tryPromise({ try: () => response.json(), catch: (error) => new Error(String(error)) }) // Transform YOUR data ONE Thing return { _id: data.entity_id, type: data.entity_type, name: data.display_name, properties: data.metadata, status: data.is_active ? 'active' : 'inactive', createdAt: data.created_timestamp, updatedAt: data.updated_timestamp } }), // Implement all other methods... } connections = { // Your relationship API calls... } events = { // Your analytics/logging API calls... } knowledge = { // Your search API calls... } } export function customProvider(config: any) { return Layer.succeed(DataProvider, new CustomProvider(config)) } ``` --- ## Multi-Backend Setup ### Use Multiple Backends Simultaneously ```typescript // frontend/astro.config.ts import { defineConfig } from 'astro/config' import { one } from '@one/astro-integration' import { convexProvider } from './src/providers/convex' import { supabaseProvider } from './src/providers/supabase' import { wordpressProvider } from './src/providers/wordpress' export default defineConfig({ integrations: [ one({ // Use multiple providers! providers: { // Primary: Convex for real-time data primary: convexProvider({ url: import.meta.env.PUBLIC_CONVEX_URL }), // Content: WordPress for blog content: wordpressProvider({ url: import.meta.env.WORDPRESS_URL, username: import.meta.env.WORDPRESS_USERNAME, password: import.meta.env.WORDPRESS_APP_PASSWORD }), // Analytics: Supabase for analytics analytics: supabaseProvider({ url: import.meta.env.PUBLIC_SUPABASE_URL, anonKey: import.meta.env.PUBLIC_SUPABASE_ANON_KEY }) } }) ] }) ``` ### Route Requests to Different Backends ```typescript // frontend/src/services/ThingClientService.ts import { Effect } from 'effect' import { DataProvider } from '@/providers/DataProvider' export class ThingClientService extends Effect.Service<ThingClientService>()( 'ThingClientService', { effect: Effect.gen(function* () { // Get all configured providers const providers = yield* Effect.all({ primary: DataProvider.fromTag('primary'), content: DataProvider.fromTag('content'), analytics: DataProvider.fromTag('analytics') }) return { get: (id: string, type: string) => Effect.gen(function* () { // Route based on thing type if (type === 'post' || type === 'page') { // Blog content WordPress return yield* providers.content.things.get(id) } else if (type === 'event') { // Events Analytics backend return yield* providers.analytics.things.get(id) } else { // Everything else Primary backend return yield* providers.primary.things.get(id) } }), list: (type: string, orgId?: string) => Effect.gen(function* () { // Similar routing logic const provider = this.selectProvider(type) return yield* provider.things.list({ type, groupId: orgId }) }) } }) } ) {} ``` --- ## Testing ### Mock Provider for Testing ```typescript // frontend/src/providers/__tests__/MockProvider.ts import { Effect, Layer } from 'effect' import { DataProvider, Thing } from '../DataProvider' export class MockProvider implements DataProvider { private storage = new Map<string, Thing>() things = { get: (id: string) => Effect.gen(this, function* () { const thing = this.storage.get(id) if (!thing) { return yield* Effect.fail(new ThingNotFoundError(id)) } return thing }), list: (params) => Effect.succeed( Array.from(this.storage.values()).filter( thing => thing.type === params.type ) ), create: (input) => Effect.gen(this, function* () { const id = `mock_${Date.now()}` const thing: Thing = { _id: id, type: input.type, name: input.name, properties: input.properties, status: 'active', createdAt: Date.now(), updatedAt: Date.now() } this.storage.set(id, thing) return id }), update: (id, updates) => Effect.gen(this, function* () { const thing = this.storage.get(id) if (!thing) { return yield* Effect.fail(new Error('Not found')) } this.storage.set(id, { ...thing, ...updates }) }), delete: (id) => Effect.gen(this, function* () { this.storage.delete(id) }) } connections = { /* mock implementations */ } events = { /* mock implementations */ } knowledge = { /* mock implementations */ } } export function mockProvider() { return Layer.succeed(DataProvider, new MockProvider()) } ``` ### Test with Mock Provider ```typescript // frontend/src/components/__tests__/ThingList.test.tsx import { describe, it, expect } from 'vitest' import { Effect } from 'effect' import { ThingClientService } from '@/services/ThingClientService' import { mockProvider } from '@/providers/__tests__/MockProvider' describe('ThingList', () => { it('lists things from provider', async () => { const program = Effect.gen(function* () { const thingService = yield* ThingClientService // Create test data yield* thingService.create({ type: 'course', name: 'Test Course', properties: {} }) // List things const things = yield* thingService.list('course') return things }) // Run with mock provider const things = await Effect.runPromise( program.pipe( Effect.provide(ThingClientService.Default), Effect.provide(mockProvider()) ) ) expect(things).toHaveLength(1) expect(things[0].name).toBe('Test Course') }) }) ``` --- ## Deployment ### Environment Configuration ```bash # frontend/.env # ======================================== # Provider Selection # ======================================== PUBLIC_DATA_PROVIDER=convex # convex | supabase | wordpress | notion | custom # ======================================== # Convex # ======================================== PUBLIC_CONVEX_URL=https://your-convex.convex.cloud # ======================================== # Supabase # ======================================== PUBLIC_SUPABASE_URL=https://your-project.supabase.co PUBLIC_SUPABASE_ANON_KEY=your-anon-key # ======================================== # WordPress # ======================================== WORDPRESS_URL=https://yoursite.com WORDPRESS_USERNAME=admin WORDPRESS_APP_PASSWORD=xxxx xxxx xxxx xxxx # ======================================== # Notion # ======================================== NOTION_API_KEY=secret_xxx NOTION_DATABASE_ID=your-database-id # ======================================== # Custom API # ======================================== CUSTOM_API_URL=https://api.yoursite.com CUSTOM_API_KEY=your-api-key ``` ### Deploy Frontend ```bash # Build frontend cd frontend npm run build # Deploy to Cloudflare Pages / Vercel / Netlify # Set environment variables in hosting platform # Frontend connects to backend via DataProvider # Backend can be anywhere (Convex, Supabase, your API, etc.) ``` --- ## Summary ### Key Principles 1. **Universal Interface**: DataProvider works with ANY backend 2. **Backend-Agnostic Frontend**: Components never know which backend 3. **Easy Switching**: Change ONE line to swap backends 4. **Multi-Backend**: Use multiple backends simultaneously 5. **Type-Safe**: Effect.ts ensures correctness 6. **Testable**: Mock providers for testing ### Implementation Checklist - [ ] Define DataProvider interface - [ ] Implement provider for your backend - [ ] Transform backend data ONE Things - [ ] Configure Astro to use provider - [ ] Test CRUD operations - [ ] Deploy frontend + backend ### Next Steps 1. Choose your backend(s) 2. Implement DataProvider for it 3. Update Astro config 4. Test with your data 5. Deploy --- **ONE Frontend + ANY Backend = Unlimited Flexibility** Zero lock-in. Maximum flexibility. One interface.