Data Adapter Architecture
Atlas uses a pluggable adapter pattern to support multiple data sources. This document explains the architecture and how to create custom adapters.
Overview
┌─────────────────────────────────────────────────────────────┐
│ DataContext │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Adapter Factory (createAdapter) │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Static │ │Supabase │ │ API │ ... │ │
│ │ │ Adapter │ │ Adapter │ │ Adapter │ │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ └───────│────────────│────────────│─────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ Unified MapEvent[] │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘Adapters are created dynamically via a factory function based on the data source configuration. Each fetch operation creates fresh adapter instances.
Adapter Interface
All adapters implement the DataAdapter interface:
// src/adapters/types.ts
export interface DataAdapter {
/** Unique identifier */
readonly id: string;
/** Human-readable name */
readonly name: string;
/** Fetch all events, optionally filtered */
getMapEvents(filters?: MapEventFilters): Promise<MapEvent[]>;
/** Fetch a single event by ID */
getMapEventById(id: string): Promise<MapEvent | null>;
/** Get available filter options from this source */
getFilterOptions(): Promise<FilterOptions>;
/** Clear cached data (optional) */
clearCache?(): void;
}
export interface FilterOptions {
countries: string[];
siteTypes: string[];
locationSensitivities: LocationSensitivity[];
eventTypes: MapEventType[];
}Built-in Adapters
Static Adapter
Loads data from JSON files in the public/ directory. Implements caching with configurable TTL.
// src/adapters/static-adapter.ts (simplified - see source for full implementation)
export class StaticAdapter implements DataAdapter {
readonly id: string;
readonly name: string;
private path: string;
private cache: AdapterCache<MapEvent[]>;
constructor(config: DataSourceConfig) {
this.id = config.id;
this.name = config.name;
this.path = config.adapterConfig.path as string;
this.cache = new AdapterCache({ ttlMs: config.adapterConfig.cacheTtlMs });
}
async getMapEvents(filters?: MapEventFilters): Promise<MapEvent[]> {
const cacheKey = 'events';
// Return cached data if available and not expired
const cached = this.cache.get(cacheKey);
if (cached) return applyMapEventFilters(cached, filters);
// Fetch and parse JSON
const response = await fetch(this.path);
const data = await response.json();
// Handle both array and { events: [...] } formats
const rawEvents = Array.isArray(data) ? data : data.events || [];
// Validate, cache, and return
const { valid } = validateMapEventArray(rawEvents);
this.cache.set(cacheKey, valid);
return applyMapEventFilters(valid, filters);
}
async getMapEventById(id: string): Promise<MapEvent | null> {
const mapEvents = await this.getMapEvents();
return mapEvents.find(i => i.id === id) || null;
}
clearCache(): void {
this.cache.clear();
}
}Supabase Adapter
Connects to a Supabase PostgreSQL database using RPC functions. Fetches all published events and applies filters client-side for flexibility.
// src/adapters/supabase-adapter.ts (simplified - see source for full implementation)
export class SupabaseAdapter implements DataAdapter {
readonly id: string;
readonly name: string;
private cache: AdapterCache<MapEvent[]>;
constructor(config: DataSourceConfig) {
this.id = config.id;
this.name = config.name;
this.cache = new AdapterCache({ ttlMs: config.adapterConfig.cacheTtlMs });
}
async getMapEvents(filters?: MapEventFilters): Promise<MapEvent[]> {
const cacheKey = 'events';
// Check cache first
const cached = this.cache.get(cacheKey);
if (cached) return applyMapEventFilters(cached, filters);
// Fetch via RPC (uses direct fetch to Supabase REST API)
const rows = await this.rpc(SUPABASE_RPC.GET_ALL_EVENTS, {
p_limit: QUERY_LIMITS.ALL_EVENTS,
p_status: 'published',
});
// Transform rows to MapEvent format
const mapEvents = rows.map(rowToMapEvent);
this.cache.set(cacheKey, mapEvents);
// Apply filters client-side
return applyMapEventFilters(mapEvents, filters);
}
clearCache(): void {
this.cache.clear();
}
}Note: The Supabase adapter uses RPC functions for data retrieval, with filtering applied client-side. This allows for flexible filtering without requiring database changes.
API Adapter
Fetches from external REST APIs. Converts filters to query parameters for server-side filtering when supported.
// src/adapters/api-adapter.ts
export class ApiAdapter implements DataAdapter {
readonly id: string;
readonly name: string;
private endpoint: string;
private headers: Record<string, string>;
private cache: AdapterCache<MapEvent[]>;
constructor(config: DataSourceConfig) {
this.id = config.id;
this.name = config.name;
this.endpoint = config.adapterConfig.endpoint as string;
this.cache = new AdapterCache({ ttlMs: config.adapterConfig.cacheTtlMs });
this.headers = {
'Content-Type': 'application/json',
...(config.adapterConfig.apiKey && {
'Authorization': `Bearer ${config.adapterConfig.apiKey}`
}),
...config.adapterConfig.headers,
};
}
async getMapEvents(filters?: MapEventFilters): Promise<MapEvent[]> {
// Build URL with filter query params
const url = new URL(this.endpoint);
if (filters) {
// Convert filters to query params (inline)
if (filters.countries?.length) {
url.searchParams.set('countries', filters.countries.join(','));
}
// ... other filter params
}
const response = await fetch(url.toString(), {
headers: this.headers,
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const data = await response.json();
// Handle both array and { events: [...] } formats
const mapEvents = Array.isArray(data) ? data : data.events;
return mapEvents;
}
// ... other methods
}Adapter Factory
Adapters are created via a factory function:
// src/adapters/index.ts
export function createAdapter(config: DataSourceConfig): DataAdapter {
switch (config.adapter) {
case 'static':
return new StaticAdapter(config);
case 'supabase':
return new SupabaseAdapter(config);
case 'api':
return new ApiAdapter(config);
default:
throw new Error(`Unknown adapter type: ${config.adapter}`);
}
}Creating Custom Adapters
Step 1: Implement the Interface
// src/adapters/my-adapter.ts
import type { DataAdapter, MapEvent, DataSourceConfig, FilterOptions } from './types';
export class MyAdapter implements DataAdapter {
readonly id: string;
readonly name: string;
private config: MyAdapterConfig;
constructor(config: DataSourceConfig) {
this.id = config.id;
this.name = config.name;
this.config = config.adapterConfig as MyAdapterConfig;
}
async getMapEvents(): Promise<MapEvent[]> {
// Your data fetching logic
const rawData = await this.fetchFromMySource();
// Transform to MapEvent format
return rawData.map(this.transformToMapEvent);
}
async getMapEventById(id: string): Promise<MapEvent | null> {
const mapEvents = await this.getMapEvents();
return mapEvents.find(i => i.id === id) || null;
}
async getFilterOptions(): Promise<FilterOptions> {
const mapEvents = await this.getMapEvents();
return {
countries: [...new Set(mapEvents.map(i => i.location?.country))],
siteTypes: [...new Set(mapEvents.map(i => i.location?.siteType))],
// ... extract other options
};
}
private async fetchFromMySource(): Promise<RawData[]> {
// Implementation
}
private transformToMapEvent(raw: RawData): MapEvent {
return {
id: raw.id,
status: 'published',
createdAt: raw.created,
updatedAt: raw.modified,
temporal: {
date: raw.date,
dateCertainty: 'exact',
},
location: {
name: raw.location,
country: raw.country,
latitude: raw.lat,
longitude: raw.lng,
siteType: mapSiteType(raw.type),
locationSensitivity: 'standard',
},
// ... map other fields
};
}
}Step 2: Define Config Type
interface MyAdapterConfig {
connectionString: string;
apiVersion?: string;
// ... your config options
}Step 3: Register in Factory
// src/adapters/index.ts
import { MyAdapter } from './my-adapter';
export function createAdapter(config: DataSourceConfig): DataAdapter {
switch (config.adapter) {
// ... existing cases
case 'my-adapter':
return new MyAdapter(config);
}
}Step 4: Use in Config
// map.config.ts
dataSources: [
{
id: 'my-source',
name: 'My Data Source',
adapter: 'my-adapter',
adapterConfig: {
connectionString: 'my://connection',
apiVersion: 'v2',
},
enabled: true,
},
]Error Handling
Adapters should handle errors gracefully:
async getMapEvents(): Promise<MapEvent[]> {
try {
const response = await fetch(this.endpoint);
if (!response.ok) {
throw new AdapterError(
`HTTP ${response.status}`,
this.id,
'FETCH_FAILED'
);
}
return await response.json();
} catch (error) {
if (error instanceof AdapterError) throw error;
// Wrap unknown errors
throw new AdapterError(
error.message,
this.id,
'UNKNOWN_ERROR'
);
}
}Caching Strategy
Adapters can implement caching:
class CachedAdapter implements DataAdapter {
private cache: Map<string, { data: MapEvent[], timestamp: number }> = new Map();
private ttl = 5 * 60 * 1000; // 5 minutes
async getMapEvents(): Promise<MapEvent[]> {
const cached = this.cache.get('events');
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
const fresh = await this.fetchFresh();
this.cache.set('events', { data: fresh, timestamp: Date.now() });
return fresh;
}
invalidateCache() {
this.cache.clear();
}
}Testing Adapters
// src/adapters/__tests__/my-adapter.test.ts
describe('MyAdapter', () => {
it('fetches events', async () => {
const adapter = new MyAdapter({
id: 'test',
name: 'Test',
adapter: 'my-adapter',
adapterConfig: { /* mock config */ },
enabled: true,
});
const mapEvents = await adapter.getMapEvents();
expect(mapEvents).toBeInstanceOf(Array);
expect(mapEvents[0]).toMatchObject({
id: expect.any(String),
temporal: expect.any(Object),
location: expect.any(Object),
});
});
it('handles errors gracefully', async () => {
// Mock a failing request
const adapter = new MyAdapter({ /* ... */ });
await expect(adapter.getMapEvents()).rejects.toThrow(AdapterError);
});
});