// OGraph API client - v2.4 Event-Sourced API import { loadConfig } from './config.js' // ─── Types ───────────────────────────────────────────────────────────────────── export interface ObjectDef { name: string created_at?: number } export interface ObjectInstance { id: number type: string created_at?: number } export interface EventDefProperty { type: 'ref' | 'string' | 'number' | 'boolean' object_type?: string } export interface EventDef { name: string hash?: string schema: { properties: Record } created_at?: number } export interface OEvent { id: number type_hash: string payload: Record created_at?: number } export interface ProjectionDefSource { event_def: string bindings: Record expression: string } export interface ProjectionDef { name: string sources?: ProjectionDefSource[] params?: Record value_schema?: { type: string } initial_value?: unknown } export interface Reaction { id: number projection_def_hash: string params_hash: string params: Record action: 'webhook' | 'emit_event' | 'handler' webhook_url?: string emit_event_type?: string emit_payload_template?: string handler_code?: string handler_timeout_ms?: number created_at: number } export interface ApiKey { id: number name: string prefix: string allowed_events?: string[] rate_limit?: number created_at: number } export interface ReactionLog { id: number reaction_id: number event_id: number status: string created_at: number } export interface RequestLog { id: number api_key_id: number method: string path: string status: number created_at: number } export interface EmitEventResponse { event: OEvent reactions_fired: number } export interface HealthResponse { status: string version: string } // ─── Client Class ────────────────────────────────────────────────────────────── export class OGraphClient { private endpoint?: string private token?: string async init(): Promise { const config = await loadConfig() this.endpoint = config.endpoint this.token = config.token if (!this.endpoint) { throw new Error('API endpoint not configured. Run: ograph config set endpoint ') } if (!this.token) { throw new Error('Auth token not configured. Run: ograph config set token ') } } private async request(path: string, options: RequestInit = {}, retries = 2): Promise { const url = `${this.endpoint}${path}` const headers = new Headers(options.headers) headers.set('Authorization', `Bearer ${this.token}`) if (options.body && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json') } let lastError: Error | undefined for (let attempt = 0; attempt <= retries; attempt++) { try { const response = await fetch(url, { ...options, headers }) // CF 1042 returns 404 with HTML — detect and retry const contentType = response.headers.get('content-type') ?? '' if (!contentType.includes('application/json')) { const text = await response.text() if (text.includes('1042') || text.includes('DOCTYPE')) { lastError = new Error('Worker not reachable (CF 1042 — edge propagation delay)') if (attempt < retries) { await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))) continue } throw lastError } throw new Error(`Unexpected response: ${text.slice(0, 100)}`) } const result = await response.json() if (!response.ok) { if (response.status === 401) { throw new Error('Authentication failed. Check your token.') } const errorMessage = (result as { error?: string }).error ?? `HTTP ${response.status}: ${response.statusText}` throw new Error(errorMessage) } return result as T } catch (error) { if (error instanceof Error && error.message.includes('fetch')) { throw new Error(`Cannot reach OGraph API at ${this.endpoint}`) } // Retry on CF 1042 / propagation errors if (error instanceof Error && error.message.includes('1042') && attempt < retries) { await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))) lastError = error continue } throw error } } throw lastError ?? new Error('Request failed after retries') } // ─── Object-Defs ─────────────────────────────────────────────────────────────── async listObjectDefs(): Promise { const res = await this.request<{ object_defs: ObjectDef[] }>('/object-defs') return res.object_defs } async createObjectDef(name: string): Promise { return this.request('/object-defs', { method: 'POST', body: JSON.stringify({ name }), }) } // ─── Objects ─────────────────────────────────────────────────────────────────── async createObject(type: string): Promise { return this.request('/objects', { method: 'POST', body: JSON.stringify({ type }), }) } async getObject(id: number): Promise { return this.request(`/objects/${id}`) } async listObjects(type?: string): Promise { const path = type ? `/objects?type=${encodeURIComponent(type)}` : '/objects' const res = await this.request<{ objects: ObjectInstance[] }>(path) return res.objects } // ─── Event-Defs ─────────────────────────────────────────────────────────────── async listEventDefs(): Promise { const res = await this.request<{ event_defs: EventDef[] }>('/event-defs') return res.event_defs } async createEventDef(name: string, schema: { properties: Record }): Promise { return this.request('/event-defs', { method: 'POST', body: JSON.stringify({ name, schema }), }) } // ─── Events ──────────────────────────────────────────────────────────────────── async emitEvent(type: string, payload: Record): Promise { return this.request('/events', { method: 'POST', body: JSON.stringify({ type, payload }), }) } async getEvent(id: number): Promise { return this.request(`/events/${id}`) } async findEventsByRef(ref: number): Promise { const res = await this.request<{ events: OEvent[] }>(`/events?ref=${ref}`) return res.events } // ─── Projection-Defs ────────────────────────────────────────────────────────── async listProjectionDefs(): Promise { const res = await this.request<{ projection_defs: ProjectionDef[] }>('/projection-defs') return res.projection_defs } async createProjectionDef( name: string, sources: ProjectionDefSource[], params: Record, value_schema: { type: string }, initial_value: unknown, ): Promise { return this.request('/projection-defs', { method: 'POST', body: JSON.stringify({ name, sources, params, value_schema, initial_value }), }) } // ─── Projections ────────────────────────────────────────────────────────────── async getProjection(name: string, params?: Record): Promise { const qs = params && Object.keys(params).length > 0 ? '?' + new URLSearchParams(params).toString() : '' const res = await this.request<{ value: unknown }>(`/projections/${encodeURIComponent(name)}${qs}`) return res.value } // ─── Reactions ──────────────────────────────────────────────────────────────── async createReaction( projectionDef: string, params: Record, options: { action?: 'webhook' | 'emit_event' | 'handler' webhook_url?: string emit_event_type?: string emit_payload_template?: string handler_code?: string handler_timeout_ms?: number }, ): Promise { const action = options.action ?? 'webhook' return this.request('/reactions', { method: 'POST', body: JSON.stringify({ projection_def: projectionDef, params, action, webhook_url: options.webhook_url, emit_event_type: options.emit_event_type, emit_payload_template: options.emit_payload_template, handler_code: options.handler_code, handler_timeout_ms: options.handler_timeout_ms, }), }) } async listReactions(): Promise { const res = await this.request<{ reactions: Reaction[] }>('/reactions') return res.reactions } async deleteReaction(id: number): Promise<{ ok: boolean }> { return this.request<{ ok: boolean }>(`/reactions/${id}`, { method: 'DELETE' }) } // ─── Reaction Logs ───────────────────────────────────────────────────────── async listReactionLogs( limit?: number, offset?: number, reactionId?: number, ): Promise<{ reaction_logs: ReactionLog[]; total: number }> { const params = new URLSearchParams() if (limit !== undefined) params.set('limit', String(limit)) if (offset !== undefined) params.set('offset', String(offset)) if (reactionId !== undefined) params.set('reaction_id', String(reactionId)) const qs = params.toString() return this.request<{ reaction_logs: ReactionLog[]; total: number }>( `/reaction-logs${qs ? `?${qs}` : ''}`, ) } // ─── Request Logs ───────────────────────────────────────────────────────────── async listRequestLogs( limit?: number, offset?: number, apiKeyId?: number, ): Promise<{ request_logs: RequestLog[]; total: number }> { const params = new URLSearchParams() if (limit !== undefined) params.set('limit', String(limit)) if (offset !== undefined) params.set('offset', String(offset)) if (apiKeyId !== undefined) params.set('api_key_id', String(apiKeyId)) const qs = params.toString() return this.request<{ request_logs: RequestLog[]; total: number }>( `/request-logs${qs ? `?${qs}` : ''}`, ) } // ─── API Keys ───────────────────────────────────────────────────────────────── async createApiKey( name: string, allowedEvents?: string[], rateLimit?: number, ): Promise<{ api_key: ApiKey; plaintext_key: string }> { const body: Record = { name } if (allowedEvents) body.allowed_events = allowedEvents if (rateLimit !== undefined) body.rate_limit = rateLimit return this.request<{ api_key: ApiKey; plaintext_key: string }>('/api-keys', { method: 'POST', body: JSON.stringify(body), }) } async listApiKeys( limit?: number, offset?: number, ): Promise<{ api_keys: ApiKey[]; total: number }> { const params = new URLSearchParams() if (limit !== undefined) params.set('limit', String(limit)) if (offset !== undefined) params.set('offset', String(offset)) const qs = params.toString() return this.request<{ api_keys: ApiKey[]; total: number }>( `/api-keys${qs ? `?${qs}` : ''}`, ) } async deleteApiKey(id: number): Promise<{ deleted: number }> { return this.request<{ deleted: number }>(`/api-keys/${id}`, { method: 'DELETE' }) } // ─── Schema ──────────────────────────────────────────────────────────────────── async getSchema(): Promise<{ object_defs: ObjectDef[]; event_defs: EventDef[]; projection_defs: ProjectionDef[] }> { return this.request<{ object_defs: ObjectDef[]; event_defs: EventDef[]; projection_defs: ProjectionDef[] }>('/schema') } // ─── Health ──────────────────────────────────────────────────────────────────── async health(): Promise { return this.request('/health') } }