ograph/packages/cli/src/client.ts

387 lines
14 KiB
TypeScript

// 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<string, EventDefProperty> }
created_at?: number
}
export interface OEvent {
id: number
type_hash: string
payload: Record<string, unknown>
created_at?: number
}
export interface ProjectionDefSource {
event_def: string
bindings: Record<string, string>
expression: string
}
export interface ProjectionDef {
name: string
sources?: ProjectionDefSource[]
params?: Record<string, { type: 'ref'; object_type?: string }>
value_schema?: { type: string }
initial_value?: unknown
}
export interface Reaction {
id: number
projection_def_hash: string
params_hash: string
params: Record<string, unknown>
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<void> {
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 <url>')
}
if (!this.token) {
throw new Error('Auth token not configured. Run: ograph config set token <token>')
}
}
private async request<T>(path: string, options: RequestInit = {}, retries = 2): Promise<T> {
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<ObjectDef[]> {
const res = await this.request<{ object_defs: ObjectDef[] }>('/object-defs')
return res.object_defs
}
async createObjectDef(name: string): Promise<ObjectDef> {
return this.request<ObjectDef>('/object-defs', {
method: 'POST',
body: JSON.stringify({ name }),
})
}
// ─── Objects ───────────────────────────────────────────────────────────────────
async createObject(type: string): Promise<ObjectInstance> {
return this.request<ObjectInstance>('/objects', {
method: 'POST',
body: JSON.stringify({ type }),
})
}
async getObject(id: number): Promise<ObjectInstance> {
return this.request<ObjectInstance>(`/objects/${id}`)
}
async listObjects(type?: string): Promise<ObjectInstance[]> {
const path = type ? `/objects?type=${encodeURIComponent(type)}` : '/objects'
const res = await this.request<{ objects: ObjectInstance[] }>(path)
return res.objects
}
// ─── Event-Defs ───────────────────────────────────────────────────────────────
async listEventDefs(): Promise<EventDef[]> {
const res = await this.request<{ event_defs: EventDef[] }>('/event-defs')
return res.event_defs
}
async createEventDef(name: string, schema: { properties: Record<string, EventDefProperty> }): Promise<EventDef> {
return this.request<EventDef>('/event-defs', {
method: 'POST',
body: JSON.stringify({ name, schema }),
})
}
// ─── Events ────────────────────────────────────────────────────────────────────
async emitEvent(type: string, payload: Record<string, unknown>): Promise<EmitEventResponse> {
return this.request<EmitEventResponse>('/events', {
method: 'POST',
body: JSON.stringify({ type, payload }),
})
}
async getEvent(id: number): Promise<OEvent> {
return this.request<OEvent>(`/events/${id}`)
}
async findEventsByRef(ref: number): Promise<OEvent[]> {
const res = await this.request<{ events: OEvent[] }>(`/events?ref=${ref}`)
return res.events
}
// ─── Projection-Defs ──────────────────────────────────────────────────────────
async listProjectionDefs(): Promise<ProjectionDef[]> {
const res = await this.request<{ projection_defs: ProjectionDef[] }>('/projection-defs')
return res.projection_defs
}
async createProjectionDef(
name: string,
sources: ProjectionDefSource[],
params: Record<string, { type: 'ref'; object_type?: string }>,
value_schema: { type: string },
initial_value: unknown,
): Promise<ProjectionDef> {
return this.request<ProjectionDef>('/projection-defs', {
method: 'POST',
body: JSON.stringify({ name, sources, params, value_schema, initial_value }),
})
}
// ─── Projections ──────────────────────────────────────────────────────────────
async getProjection(name: string, params?: Record<string, string>): Promise<unknown> {
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<string, unknown>,
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<Reaction> {
const action = options.action ?? 'webhook'
return this.request<Reaction>('/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<Reaction[]> {
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<string, unknown> = { 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<HealthResponse> {
return this.request<HealthResponse>('/health')
}
}