387 lines
14 KiB
TypeScript
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')
|
|
}
|
|
}
|