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