feat: initial ograph repo — engine (85 tests) + cli (31 tests)

Extracted from uncaged monorepo (oc-xiaoju/uncaged).
Resolves oc-xiaoju/uncaged#224.

- @uncaged/ograph: CF Worker engine (events, projections, reactions)
- @uncaged/ograph-cli: CLI for managing OGraph instances
- Removed @uncaged/oid dependency (unused)
- 116 tests, all passing
- CI: GitHub Actions

小橘 🍊(NEKO Team)
This commit is contained in:
小橘 2026-04-12 23:43:56 +00:00
commit d84a860d15
50 changed files with 12682 additions and 0 deletions

18
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,18 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install
- run: npm test

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.wrangler/
*.log

51
README.md Normal file
View File

@ -0,0 +1,51 @@
# OGraph
**Event Sourcing + Projection + Reaction engine on Cloudflare Workers.**
Part of the [Uncaged](https://github.com/oc-xiaoju/uncaged) ecosystem.
## Packages
| Package | Description | npm |
|---|---|---|
| [`@uncaged/ograph`](packages/engine/) | CF Worker engine — events, projections, reactions | [![npm](https://img.shields.io/npm/v/@uncaged/ograph)](https://www.npmjs.com/package/@uncaged/ograph) |
| [`@uncaged/ograph-cli`](packages/cli/) | CLI for managing OGraph instances | [![npm](https://img.shields.io/npm/v/@uncaged/ograph-cli)](https://www.npmjs.com/package/@uncaged/ograph-cli) |
## Core Concepts
- **Event** — Immutable facts with typed properties and object references
- **Projection** — Derived state computed incrementally from events via reducers
- **Reaction** — Side effects triggered by projection state changes (webhooks, event emission, handlers)
## Quick Start
```bash
npm install -g @uncaged/ograph-cli
ograph deploy # Deploy to Cloudflare Workers
ograph event-def add # Define event types
ograph event add # Emit events
ograph projection list # Query projections
```
## Development
```bash
npm install
npm test # Run all tests
cd packages/engine && npm run dev # Local dev server
```
## Architecture
- **D1** for storage (events, projections, reactions)
- **Hono** for API routing
- **Incremental reduce** — projections track `last_event_id` for O(delta) updates
- **Dynamic handlers**`new Function()` sandboxed execution with emit/log/kv API injection
## License
MIT
---
*Built by 小橘 🍊 & 小墨 🖊️ — NEKO + KUMA Teams*

5867
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "ograph",
"private": true,
"workspaces": [
"packages/engine",
"packages/cli"
],
"scripts": {
"test": "npm run test --workspaces",
"lint": "eslint packages/*/src/",
"lint:fix": "eslint packages/*/src/ --fix"
},
"devDependencies": {
"eslint": "^9.0.0",
"typescript": "^6.0.2"
}
}

46
packages/cli/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "@uncaged/ograph-cli",
"version": "0.1.0",
"description": "OGraph CLI for object-graph database operations",
"type": "module",
"bin": {
"ograph": "./dist/index.js"
},
"files": [
"dist/"
],
"exports": {
".": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"test:run": "vitest run"
},
"keywords": [
"ograph-cli",
"graph",
"database",
"cli",
"object"
],
"author": "小墨 🖊️",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/oc-xiaoju/uncaged.git",
"directory": "packages/ograph-cli"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"commander": "^12.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0"
}
}

254
packages/cli/src/client.ts Normal file
View File

@ -0,0 +1,254 @@
// 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'
webhook_url?: string
emit_event_type?: string
emit_payload_template?: string
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 = {}): 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')
}
try {
const response = await fetch(url, { ...options, headers })
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}`)
}
throw error
}
}
// ─── 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'
webhook_url?: string
emit_event_type?: string
emit_payload_template?: string
},
): 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,
}),
})
}
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' })
}
// ─── Health ────────────────────────────────────────────────────────────────────
async health(): Promise<HealthResponse> {
return this.request<HealthResponse>('/health')
}
}

View File

@ -0,0 +1,103 @@
// Configuration commands
import { Command } from 'commander'
import { loadConfig, setConfigValue } from '../config.js'
// ─── Colors ────────────────────────────────────────────────────────────────────
const c = {
reset: '\x1b[0m',
green: '\x1b[32m',
cyan: '\x1b[36m',
yellow: '\x1b[33m',
red: '\x1b[31m',
}
function ok(msg: string) {
console.log(`${c.green}${c.reset} ${msg}`)
}
function info(msg: string) {
console.log(`${c.cyan}${c.reset} ${msg}`)
}
function warn(msg: string) {
console.log(`${c.yellow}${c.reset} ${msg}`)
}
function fail(msg: string) {
console.error(`${c.red}${c.reset} ${msg}`)
}
// ─── Commands ──────────────────────────────────────────────────────────────────
export function createConfigCommand(): Command {
const config = new Command('config')
config.description('Configuration management')
// config set
const setCmd = new Command('set')
setCmd.description('Set configuration value')
setCmd.argument('<key>', 'Configuration key (endpoint|token)')
setCmd.argument('<value>', 'Configuration value')
setCmd.action(async (key: string, value: string) => {
try {
if (!['endpoint', 'token'].includes(key)) {
fail(`Invalid config key: ${key}. Valid keys: endpoint, token`)
process.exit(1)
}
await setConfigValue(key as 'endpoint' | 'token', value)
if (key === 'token') {
ok(`Set ${key}: ${value.slice(0, 8)}${'*'.repeat(Math.max(0, value.length - 8))}`)
} else {
ok(`Set ${key}: ${value}`)
}
} catch (error) {
fail(`Failed to set config: ${error}`)
process.exit(1)
}
})
// config show
const showCmd = new Command('show')
showCmd.description('Show current configuration')
showCmd.action(async () => {
try {
const config = await loadConfig()
if (Object.keys(config).length === 0) {
warn('No configuration found')
info('Use "ograph config set <key> <value>" to configure')
return
}
console.log('Current configuration:')
console.log()
if (config.endpoint) {
console.log(` endpoint: ${config.endpoint}`)
}
if (config.token) {
const maskedToken = config.token.slice(0, 8) + '*'.repeat(Math.max(0, config.token.length - 8))
console.log(` token: ${maskedToken}`)
}
// Show missing required config
const missing = []
if (!config.endpoint) missing.push('endpoint')
if (!config.token) missing.push('token')
if (missing.length > 0) {
console.log()
warn(`Missing required config: ${missing.join(', ')}`)
}
} catch (error) {
fail(`Failed to load config: ${error}`)
process.exit(1)
}
})
config.addCommand(setCmd)
config.addCommand(showCmd)
return config
}

View File

@ -0,0 +1,80 @@
// event-defs commands
import { Command } from 'commander'
import { OGraphClient } from '../client.js'
import type { EventDefProperty } from '../client.js'
const c = {
reset: '\x1b[0m',
green: '\x1b[32m',
cyan: '\x1b[36m',
red: '\x1b[31m',
bold: '\x1b[1m',
}
function fail(msg: string) {
console.error(`${c.red}${c.reset} ${msg}`)
}
export function createEventDefsCommand(): Command {
const cmd = new Command('event-defs')
cmd.description('Manage event type definitions')
cmd.option('--json', 'output raw JSON')
// Default action: list
cmd.action(async (opts: { json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const defs = await client.listEventDefs()
if (opts.json) {
console.log(JSON.stringify(defs, null, 2))
return
}
if (defs.length === 0) {
console.log('No event types defined.')
return
}
console.log(`${c.bold}Event Types${c.reset}`)
for (const d of defs) {
const props = Object.keys(d.schema?.properties ?? {}).join(', ')
console.log(` ${c.cyan}${d.name}${c.reset} {${props}}`)
}
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
// create subcommand
const create = new Command('create')
create.description('Register or update an event type')
create.argument('<name>', 'Event type name')
create.requiredOption('--schema <json>', 'Schema JSON, e.g. {"prop": {"type": "string"}}')
create.option('--json', 'output raw JSON')
create.action(async (name: string, opts: { schema: string; json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
let properties: Record<string, EventDefProperty>
try {
properties = JSON.parse(opts.schema) as Record<string, EventDefProperty>
} catch {
fail('Invalid JSON for --schema')
process.exit(1)
return
}
const def = await client.createEventDef(name, { properties })
if (opts.json) {
console.log(JSON.stringify(def, null, 2))
return
}
console.log(`${c.green}${c.reset} Registered event type: ${c.cyan}${def.name}${c.reset}`)
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
cmd.addCommand(create)
return cmd
}

View File

@ -0,0 +1,109 @@
// events commands
import { Command } from 'commander'
import { OGraphClient } from '../client.js'
const c = {
reset: '\x1b[0m',
green: '\x1b[32m',
cyan: '\x1b[36m',
red: '\x1b[31m',
bold: '\x1b[1m',
}
function fail(msg: string) {
console.error(`${c.red}${c.reset} ${msg}`)
}
export function createEventsCommand(): Command {
const cmd = new Command('events')
cmd.description('Emit and query events')
// emit
const emit = new Command('emit')
emit.description('Emit an event')
emit.argument('<type>', 'Event type name')
emit.requiredOption('--payload <json>', 'Event payload JSON')
emit.option('--json', 'output raw JSON')
emit.action(async (type: string, opts: { payload: string; json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
let payload: Record<string, unknown>
try {
payload = JSON.parse(opts.payload) as Record<string, unknown>
} catch {
fail('Invalid JSON for --payload')
process.exit(1)
return
}
const result = await client.emitEvent(type, payload)
if (opts.json) {
console.log(JSON.stringify(result, null, 2))
return
}
console.log(
`${c.green}${c.reset} Emitted event: ${c.cyan}${result.event.id}${c.reset} (reactions_fired: ${result.reactions_fired})`,
)
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
// get
const get = new Command('get')
get.description('Get an event by ID')
get.argument('<id>', 'Event ID')
get.option('--json', 'output raw JSON')
get.action(async (id: string, opts: { json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const event = await client.getEvent(parseInt(id, 10))
if (opts.json) {
console.log(JSON.stringify(event, null, 2))
return
}
console.log(`${c.bold}Event${c.reset}`)
console.log(` id: ${c.cyan}${event.id}${c.reset}`)
console.log(` type_hash: ${event.type_hash}`)
console.log(` payload: ${JSON.stringify(event.payload)}`)
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
// find
const find = new Command('find')
find.description('Find events by object reference')
find.requiredOption('--ref <object-id>', 'Object ID to find related events')
find.option('--json', 'output raw JSON')
find.action(async (opts: { ref: string; json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const events = await client.findEventsByRef(parseInt(opts.ref, 10))
if (opts.json) {
console.log(JSON.stringify(events, null, 2))
return
}
if (events.length === 0) {
console.log('No events found for this object.')
return
}
console.log(`${c.bold}Events${c.reset}`)
for (const e of events) {
console.log(` ${c.cyan}${e.id}${c.reset} ${e.type_hash}`)
}
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
cmd.addCommand(emit)
cmd.addCommand(get)
cmd.addCommand(find)
return cmd
}

View File

@ -0,0 +1,40 @@
// health command
import { Command } from 'commander'
import { OGraphClient } from '../client.js'
const c = {
reset: '\x1b[0m',
green: '\x1b[32m',
cyan: '\x1b[36m',
red: '\x1b[31m',
bold: '\x1b[1m',
}
function fail(msg: string) {
console.error(`${c.red}${c.reset} ${msg}`)
}
export function createHealthCommand(): Command {
const cmd = new Command('health')
cmd.description('Check API health')
cmd.option('--json', 'output raw JSON')
cmd.action(async (opts: { json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const result = await client.health()
if (opts.json) {
console.log(JSON.stringify(result, null, 2))
return
}
const statusColor = result.status === 'ok' ? c.green : c.red
console.log(`${c.bold}Health${c.reset}`)
console.log(` status: ${statusColor}${result.status}${c.reset}`)
console.log(` version: ${c.cyan}${result.version}${c.reset}`)
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
return cmd
}

View File

@ -0,0 +1,70 @@
// object-defs commands
import { Command } from 'commander'
import { OGraphClient } from '../client.js'
const c = {
reset: '\x1b[0m',
green: '\x1b[32m',
cyan: '\x1b[36m',
yellow: '\x1b[33m',
red: '\x1b[31m',
bold: '\x1b[1m',
}
function fail(msg: string) {
console.error(`${c.red}${c.reset} ${msg}`)
}
export function createObjectDefsCommand(): Command {
const cmd = new Command('object-defs')
cmd.description('Manage object type definitions')
cmd.option('--json', 'output raw JSON')
// Default action: list
cmd.action(async (opts: { json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const defs = await client.listObjectDefs()
if (opts.json) {
console.log(JSON.stringify(defs, null, 2))
return
}
if (defs.length === 0) {
console.log('No object types defined.')
return
}
console.log(`${c.bold}Object Types${c.reset}`)
for (const d of defs) {
console.log(` ${c.cyan}${d.name}${c.reset}`)
}
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
// create subcommand
const create = new Command('create')
create.description('Register a new object type')
create.argument('<name>', 'Object type name')
create.option('--json', 'output raw JSON')
create.action(async (name: string, opts: { json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const def = await client.createObjectDef(name)
if (opts.json) {
console.log(JSON.stringify(def, null, 2))
return
}
console.log(`${c.green}${c.reset} Created object type: ${c.cyan}${def.name}${c.reset}`)
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
cmd.addCommand(create)
return cmd
}

View File

@ -0,0 +1,97 @@
// objects commands
import { Command } from 'commander'
import { OGraphClient } from '../client.js'
const c = {
reset: '\x1b[0m',
green: '\x1b[32m',
cyan: '\x1b[36m',
red: '\x1b[31m',
bold: '\x1b[1m',
}
function fail(msg: string) {
console.error(`${c.red}${c.reset} ${msg}`)
}
export function createObjectsCommand(): Command {
const cmd = new Command('objects')
cmd.description('Manage object instances')
// create
const create = new Command('create')
create.description('Create a new object instance')
create.argument('<type>', 'Object type')
create.option('--json', 'output raw JSON')
create.action(async (type: string, opts: { json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const obj = await client.createObject(type)
if (opts.json) {
console.log(JSON.stringify(obj, null, 2))
return
}
console.log(`${c.green}${c.reset} Created object: ${c.cyan}${obj.id}${c.reset} (type: ${obj.type})`)
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
// get
const get = new Command('get')
get.description('Get an object by ID')
get.argument('<id>', 'Object ID')
get.option('--json', 'output raw JSON')
get.action(async (id: string, opts: { json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const obj = await client.getObject(parseInt(id, 10))
if (opts.json) {
console.log(JSON.stringify(obj, null, 2))
return
}
console.log(`${c.bold}Object${c.reset}`)
console.log(` id: ${c.cyan}${obj.id}${c.reset}`)
console.log(` type: ${obj.type}`)
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
// list
const list = new Command('list')
list.description('List object instances')
list.option('--type <name>', 'Filter by type')
list.option('--json', 'output raw JSON')
list.action(async (opts: { type?: string; json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const objects = await client.listObjects(opts.type)
if (opts.json) {
console.log(JSON.stringify(objects, null, 2))
return
}
if (objects.length === 0) {
console.log('No objects found.')
return
}
console.log(`${c.bold}Objects${c.reset}`)
for (const o of objects) {
console.log(` ${c.cyan}${o.id}${c.reset} ${o.type}`)
}
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
cmd.addCommand(create)
cmd.addCommand(get)
cmd.addCommand(list)
return cmd
}

View File

@ -0,0 +1,144 @@
// projection-defs commands
import { Command } from 'commander'
import { OGraphClient } from '../client.js'
import type { ProjectionDefSource } from '../client.js'
const c = {
reset: '\x1b[0m',
green: '\x1b[32m',
cyan: '\x1b[36m',
red: '\x1b[31m',
bold: '\x1b[1m',
}
function fail(msg: string) {
console.error(`${c.red}${c.reset} ${msg}`)
}
export function createProjectionDefsCommand(): Command {
const cmd = new Command('projection-defs')
cmd.description('Manage projection definitions')
cmd.option('--json', 'output raw JSON')
// Default action: list
cmd.action(async (opts: { json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const defs = await client.listProjectionDefs()
if (opts.json) {
console.log(JSON.stringify(defs, null, 2))
return
}
if (defs.length === 0) {
console.log('No projection definitions found.')
return
}
console.log(`${c.bold}Projection Definitions${c.reset}`)
for (const d of defs) {
console.log(` ${c.cyan}${d.name}${c.reset} sources: ${d.sources?.length ?? 0}`)
}
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
// create subcommand
const create = new Command('create')
create.description('Register or update a projection definition')
create.argument('<name>', 'Projection name')
create.option(
'--source <json>',
'Source entry JSON (repeatable), e.g. \'{"event_def":"UserCreated","bindings":{"user":"$subject"},"expression":"1"}\'',
(val: string, acc: string[]) => {
acc.push(val)
return acc
},
[] as string[],
)
create.option('--params <json>', 'Params JSON, e.g. {"obj": {"type": "ref"}}')
create.option('--value-schema <json>', 'Value schema JSON, e.g. {"type": "number"}')
create.option('--initial-value <json>', 'Initial value JSON')
create.option('--json', 'output raw JSON')
create.action(
async (
name: string,
opts: {
source: string[]
params?: string
valueSchema?: string
initialValue?: string
json?: boolean
},
) => {
const client = new OGraphClient()
try {
await client.init()
if (!opts.source || opts.source.length === 0) {
fail('At least one --source is required')
process.exit(1)
return
}
const sources: ProjectionDefSource[] = []
for (const s of opts.source) {
try {
sources.push(JSON.parse(s) as ProjectionDefSource)
} catch {
fail(`Invalid JSON for --source: ${s}`)
process.exit(1)
return
}
}
let params: Record<string, { type: 'ref'; object_type?: string }> = {}
if (opts.params) {
try {
params = JSON.parse(opts.params) as Record<string, { type: 'ref'; object_type?: string }>
} catch {
fail('Invalid JSON for --params')
process.exit(1)
return
}
}
let value_schema: { type: string } = { type: 'number' }
if (opts.valueSchema) {
try {
value_schema = JSON.parse(opts.valueSchema) as { type: string }
} catch {
fail('Invalid JSON for --value-schema')
process.exit(1)
return
}
}
let initial_value: unknown = 0
if (opts.initialValue !== undefined) {
try {
initial_value = JSON.parse(opts.initialValue) as unknown
} catch {
fail('Invalid JSON for --initial-value')
process.exit(1)
return
}
}
const result = await client.createProjectionDef(name, sources, params, value_schema, initial_value)
if (opts.json) {
console.log(JSON.stringify(result, null, 2))
return
}
console.log(`${c.green}${c.reset} Registered projection: ${c.cyan}${result.name}${c.reset}`)
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
},
)
cmd.addCommand(create)
return cmd
}

View File

@ -0,0 +1,63 @@
// projections commands
import { Command } from 'commander'
import { OGraphClient } from '../client.js'
const c = {
reset: '\x1b[0m',
cyan: '\x1b[36m',
red: '\x1b[31m',
bold: '\x1b[1m',
}
function fail(msg: string) {
console.error(`${c.red}${c.reset} ${msg}`)
}
export function createProjectionsCommand(): Command {
const cmd = new Command('projections')
cmd.description('Query projection values')
// get
const get = new Command('get')
get.description('Get the current value of a projection')
get.argument('<name>', 'Projection name')
get.option(
'--param <key=val...>',
'Query parameters (repeatable)',
(val, prev: string[]) => {
prev.push(val)
return prev
},
[] as string[],
)
get.option('--json', 'output raw JSON')
get.action(async (name: string, opts: { param: string[]; json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const params: Record<string, string> = {}
for (const p of opts.param) {
const idx = p.indexOf('=')
if (idx === -1) {
fail(`Invalid --param format: "${p}" (expected key=value)`)
process.exit(1)
return
}
params[p.slice(0, idx)] = p.slice(idx + 1)
}
const value = await client.getProjection(name, params)
if (opts.json) {
console.log(JSON.stringify({ value }, null, 2))
return
}
console.log(`${c.bold}Projection${c.reset} ${c.cyan}${name}${c.reset}`)
console.log(` value: ${JSON.stringify(value)}`)
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
cmd.addCommand(get)
return cmd
}

View File

@ -0,0 +1,145 @@
// reactions commands
import { Command } from 'commander'
import { OGraphClient } from '../client.js'
const c = {
reset: '\x1b[0m',
green: '\x1b[32m',
cyan: '\x1b[36m',
red: '\x1b[31m',
bold: '\x1b[1m',
}
function fail(msg: string) {
console.error(`${c.red}${c.reset} ${msg}`)
}
export function createReactionsCommand(): Command {
const cmd = new Command('reactions')
cmd.description('Manage reactions (projection triggers)')
// create
const create = new Command('create')
create.description('Create a new reaction')
create.requiredOption('--projection <name>', 'Projection def name')
create.option('--params <json>', 'Params JSON')
create.option('--action <type>', 'Action type: webhook (default) or emit_event', 'webhook')
create.option('--webhook <url>', 'Webhook URL (required when --action webhook)')
create.option('--emit-type <event_type>', 'Event type to emit (required when --action emit_event)')
create.option('--emit-template <jsonata>', 'JSONata template for emitted event payload')
create.option('--json', 'output raw JSON')
create.action(
async (opts: {
projection: string
params?: string
action: string
webhook?: string
emitType?: string
emitTemplate?: string
json?: boolean
}) => {
const client = new OGraphClient()
try {
await client.init()
let params: Record<string, unknown> = {}
if (opts.params) {
try {
params = JSON.parse(opts.params) as Record<string, unknown>
} catch {
fail('Invalid JSON for --params')
process.exit(1)
return
}
}
const action = opts.action as 'webhook' | 'emit_event'
if (action !== 'webhook' && action !== 'emit_event') {
fail('--action must be "webhook" or "emit_event"')
process.exit(1)
return
}
if (action === 'webhook' && !opts.webhook) {
fail('--webhook <url> is required when --action webhook')
process.exit(1)
return
}
if (action === 'emit_event' && !opts.emitType) {
fail('--emit-type <event_type> is required when --action emit_event')
process.exit(1)
return
}
const reaction = await client.createReaction(opts.projection, params, {
action,
webhook_url: opts.webhook,
emit_event_type: opts.emitType,
emit_payload_template: opts.emitTemplate,
})
if (opts.json) {
console.log(JSON.stringify(reaction, null, 2))
return
}
console.log(
`${c.green}${c.reset} Created reaction: ${c.cyan}${reaction.id}${c.reset} (action: ${reaction.action})`,
)
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
},
)
// list
const list = new Command('list')
list.description('List all reactions')
list.option('--json', 'output raw JSON')
list.action(async (opts: { json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const reactions = await client.listReactions()
if (opts.json) {
console.log(JSON.stringify(reactions, null, 2))
return
}
if (reactions.length === 0) {
console.log('No reactions found.')
return
}
console.log(`${c.bold}Reactions${c.reset}`)
for (const r of reactions) {
const target = r.action === 'emit_event' ? `emit:${r.emit_event_type}` : (r.webhook_url ?? '')
console.log(` ${c.cyan}${r.id}${c.reset} [${r.action}] ${target}`)
}
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
// delete
const del = new Command('delete')
del.description('Delete a reaction')
del.argument('<id>', 'Reaction ID')
del.option('--json', 'output raw JSON')
del.action(async (id: string, opts: { json?: boolean }) => {
const client = new OGraphClient()
try {
await client.init()
const result = await client.deleteReaction(parseInt(id, 10))
if (opts.json) {
console.log(JSON.stringify(result, null, 2))
return
}
console.log(`${c.green}${c.reset} Deleted reaction: ${id}`)
} catch (err) {
fail(String(err instanceof Error ? err.message : err))
process.exit(1)
}
})
cmd.addCommand(create)
cmd.addCommand(list)
cmd.addCommand(del)
return cmd
}

View File

@ -0,0 +1,65 @@
// Configuration management for OGraph CLI
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { homedir } from 'node:os'
// ─── Types ─────────────────────────────────────────────────────────────────────
export interface Config {
endpoint?: string
token?: string
}
// ─── Paths ─────────────────────────────────────────────────────────────────────
function getConfigDir(): string {
return process.env.OGRAPH_CONFIG_DIR || join(homedir(), '.config', 'ograph')
}
function getConfigPath(): string {
return join(getConfigDir(), 'config.json')
}
// ─── Config Functions ──────────────────────────────────────────────────────────
export async function loadConfig(): Promise<Config> {
const CONFIG_PATH = getConfigPath()
if (!existsSync(CONFIG_PATH)) {
return {}
}
try {
const data = await readFile(CONFIG_PATH, 'utf-8')
return JSON.parse(data)
} catch (error) {
console.warn('Warning: config.json is malformed, using empty config')
return {}
}
}
export async function saveConfig(config: Config): Promise<void> {
const CONFIG_DIR = getConfigDir()
const CONFIG_PATH = getConfigPath()
try {
// Ensure config directory exists
if (!existsSync(CONFIG_DIR)) {
await mkdir(CONFIG_DIR, { recursive: true })
}
await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8')
} catch (error) {
throw new Error(`Failed to save config: ${error}`)
}
}
export async function setConfigValue(key: keyof Config, value: string): Promise<void> {
const config = await loadConfig()
config[key] = value
await saveConfig(config)
}
export async function getConfigValue(key: keyof Config): Promise<string | undefined> {
const config = await loadConfig()
return config[key]
}

31
packages/cli/src/index.ts Normal file
View File

@ -0,0 +1,31 @@
#!/usr/bin/env node
// @uncaged/ograph-cli - OGraph CLI for Event-Sourced API v2.4
import { Command } from 'commander'
import { createConfigCommand } from './commands/config.js'
import { createObjectDefsCommand } from './commands/object-defs.js'
import { createObjectsCommand } from './commands/objects.js'
import { createEventDefsCommand } from './commands/event-defs.js'
import { createEventsCommand } from './commands/events.js'
import { createProjectionDefsCommand } from './commands/projection-defs.js'
import { createProjectionsCommand } from './commands/projections.js'
import { createReactionsCommand } from './commands/reactions.js'
import { createHealthCommand } from './commands/health.js'
// ─── Main Program ──────────────────────────────────────────────────────────────
const program = new Command()
program.name('ograph').description('OGraph CLI for Event-Sourced object-graph operations').version('0.1.0')
program.addCommand(createConfigCommand())
program.addCommand(createObjectDefsCommand())
program.addCommand(createObjectsCommand())
program.addCommand(createEventDefsCommand())
program.addCommand(createEventsCommand())
program.addCommand(createProjectionDefsCommand())
program.addCommand(createProjectionsCommand())
program.addCommand(createReactionsCommand())
program.addCommand(createHealthCommand())
program.parse()

View File

@ -0,0 +1,374 @@
// Test suite for OGraphClient v2.4
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { OGraphClient } from '../src/client.js'
// Mock the config module
vi.mock('../src/config.js', () => ({
loadConfig: vi.fn(),
}))
import { loadConfig } from '../src/config.js'
const mockLoadConfig = vi.mocked(loadConfig)
// Mock fetch globally
const mockFetch = vi.fn()
global.fetch = mockFetch
describe('OGraphClient v2.4', () => {
let client: OGraphClient
beforeEach(() => {
client = new OGraphClient()
vi.clearAllMocks()
})
// ─── init ────────────────────────────────────────────────────────────────────
describe('init', () => {
it('should throw error if endpoint not configured', async () => {
mockLoadConfig.mockResolvedValue({})
await expect(client.init()).rejects.toThrow('API endpoint not configured. Run: ograph config set endpoint <url>')
})
it('should throw error if token not configured', async () => {
mockLoadConfig.mockResolvedValue({ endpoint: 'https://api.example.com' })
await expect(client.init()).rejects.toThrow('Auth token not configured. Run: ograph config set token <token>')
})
it('should initialize successfully with both endpoint and token', async () => {
mockLoadConfig.mockResolvedValue({ endpoint: 'https://api.example.com', token: 'test-token' })
await expect(client.init()).resolves.not.toThrow()
})
})
// ─── helpers ─────────────────────────────────────────────────────────────────
async function initClient() {
mockLoadConfig.mockResolvedValue({ endpoint: 'https://api.example.com', token: 'test-token' })
await client.init()
}
function mockOk(data: unknown) {
mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(data) })
}
function mockFail(status: number, error: string) {
mockFetch.mockResolvedValue({
ok: false,
status,
statusText: error,
json: () => Promise.resolve({ error }),
})
}
// ─── object-defs ─────────────────────────────────────────────────────────────
describe('listObjectDefs', () => {
it('returns object_defs array', async () => {
await initClient()
mockOk({ object_defs: [{ name: 'user' }, { name: 'task' }] })
const result = await client.listObjectDefs()
expect(result).toEqual([{ name: 'user' }, { name: 'task' }])
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/object-defs')
})
})
describe('createObjectDef', () => {
it('POSTs to /object-defs with name', async () => {
await initClient()
mockOk({ name: 'user', created_at: 1234 })
const result = await client.createObjectDef('user')
expect(result.name).toBe('user')
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://api.example.com/object-defs')
expect(opts.method).toBe('POST')
expect(JSON.parse(opts.body as string)).toEqual({ name: 'user' })
})
})
// ─── objects ─────────────────────────────────────────────────────────────────
describe('createObject', () => {
it('POSTs to /objects with type only (no custom id)', async () => {
await initClient()
mockOk({ id: 1, type: 'user', created_at: 1234 })
const result = await client.createObject('user')
expect(result.id).toBe(1)
expect(typeof result.id).toBe('number')
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
expect(body).toEqual({ type: 'user' })
})
})
describe('getObject', () => {
it('GETs /objects/:id with numeric id', async () => {
await initClient()
mockOk({ id: 42, type: 'user', created_at: 1234 })
const result = await client.getObject(42)
expect(result.id).toBe(42)
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/objects/42')
})
})
describe('listObjects', () => {
it('GETs /objects without filter', async () => {
await initClient()
mockOk({ objects: [{ id: 1, type: 'user', created_at: 1234 }] })
const result = await client.listObjects()
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/objects')
})
it('GETs /objects?type=user with filter', async () => {
await initClient()
mockOk({ objects: [] })
await client.listObjects('user')
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/objects?type=user')
})
})
// ─── event-defs ──────────────────────────────────────────────────────────────
describe('listEventDefs', () => {
it('returns event_defs array', async () => {
await initClient()
mockOk({ event_defs: [{ name: 'UserCreated', schema: { properties: {} } }] })
const result = await client.listEventDefs()
expect(result[0].name).toBe('UserCreated')
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/event-defs')
})
})
describe('createEventDef', () => {
it('POSTs to /event-defs with name and schema', async () => {
await initClient()
const schema = { properties: { user: { type: 'ref' as const } } }
mockOk({ name: 'UserCreated', schema, hash: 'abc123' })
const result = await client.createEventDef('UserCreated', schema)
expect(result.name).toBe('UserCreated')
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
expect(body).toEqual({ name: 'UserCreated', schema })
})
})
// ─── events ──────────────────────────────────────────────────────────────────
describe('emitEvent', () => {
it('POSTs to /events and returns {event, reactions_fired}', async () => {
await initClient()
mockOk({ event: { id: 1, type_hash: 'abc123', payload: { user: 1 }, created_at: 1234 }, reactions_fired: 0 })
const result = await client.emitEvent('UserCreated', { user: 1 })
expect(result.event.id).toBe(1)
expect(typeof result.event.id).toBe('number')
expect(result.event.type_hash).toBe('abc123')
expect(result.reactions_fired).toBe(0)
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://api.example.com/events')
expect(opts.method).toBe('POST')
const body = JSON.parse(opts.body as string)
expect(body).toEqual({ type: 'UserCreated', payload: { user: 1 } })
})
})
describe('getEvent', () => {
it('GETs /events/:id with numeric id', async () => {
await initClient()
mockOk({ id: 5, type_hash: 'abc123', payload: {}, created_at: 1234 })
const result = await client.getEvent(5)
expect(result.id).toBe(5)
expect(typeof result.id).toBe('number')
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/events/5')
})
})
describe('findEventsByRef', () => {
it('GETs /events?ref=<numeric-id>', async () => {
await initClient()
mockOk({ events: [{ id: 1, type_hash: 'abc123', payload: {}, created_at: 1234 }] })
const result = await client.findEventsByRef(7)
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/events?ref=7')
})
})
// ─── projection-defs ─────────────────────────────────────────────────────────
describe('listProjectionDefs', () => {
it('returns projection_defs array', async () => {
await initClient()
mockOk({
projection_defs: [
{
name: 'userCount',
sources: [{ event_def: 'UserCreated', bindings: {}, expression: '$count + 1' }],
value_schema: { type: 'number' },
initial_value: 0,
},
],
})
const result = await client.listProjectionDefs()
expect(result[0].name).toBe('userCount')
})
})
describe('createProjectionDef', () => {
it('POSTs to /projection-defs with name, sources, params, value_schema, initial_value', async () => {
await initClient()
const sources = [{ event_def: 'UserCreated', bindings: {}, expression: '$count + 1' }]
const params = {}
const value_schema = { type: 'number' }
const initial_value = 0
mockOk({ name: 'userCount', sources, params, value_schema, initial_value })
const result = await client.createProjectionDef('userCount', sources, params, value_schema, initial_value)
expect(result.name).toBe('userCount')
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://api.example.com/projection-defs')
expect(opts.method).toBe('POST')
const body = JSON.parse(opts.body as string)
expect(body.name).toBe('userCount')
expect(body.sources).toEqual(sources)
expect(body.value_schema).toEqual(value_schema)
expect(body.initial_value).toBe(0)
})
})
// ─── projections ─────────────────────────────────────────────────────────────
describe('getProjection', () => {
it('GETs /projections/:name', async () => {
await initClient()
mockOk({ value: 42 })
const value = await client.getProjection('userCount')
expect(value).toBe(42)
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/projections/userCount')
})
it('appends params as query string', async () => {
await initClient()
mockOk({ value: 5 })
await client.getProjection('tasksByUser', { userId: 'user_01ABC' })
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/projections/tasksByUser?userId=user_01ABC')
})
})
// ─── reactions ───────────────────────────────────────────────────────────────
describe('createReaction (webhook)', () => {
it('POSTs to /reactions with action=webhook', async () => {
await initClient()
mockOk({
id: 1,
projection_def_hash: 'hashABC',
params_hash: 'paramHash',
params: {},
action: 'webhook',
webhook_url: 'https://example.com/hook',
created_at: 1234,
})
const result = await client.createReaction(
'userCount',
{},
{ action: 'webhook', webhook_url: 'https://example.com/hook' },
)
expect(result.id).toBe(1)
expect(typeof result.id).toBe('number')
expect(result.action).toBe('webhook')
expect(result.webhook_url).toBe('https://example.com/hook')
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://api.example.com/reactions')
expect(opts.method).toBe('POST')
const body = JSON.parse(opts.body as string)
expect(body.projection_def).toBe('userCount')
expect(body.action).toBe('webhook')
expect(body.webhook_url).toBe('https://example.com/hook')
})
})
describe('createReaction (emit_event)', () => {
it('POSTs to /reactions with action=emit_event', async () => {
await initClient()
mockOk({
id: 2,
projection_def_hash: 'hashABC',
params_hash: 'paramHash',
params: {},
action: 'emit_event',
emit_event_type: 'TaskCompleted',
created_at: 1234,
})
const result = await client.createReaction(
'taskStatus',
{},
{
action: 'emit_event',
emit_event_type: 'TaskCompleted',
},
)
expect(result.id).toBe(2)
expect(result.action).toBe('emit_event')
expect(result.emit_event_type).toBe('TaskCompleted')
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
expect(body.action).toBe('emit_event')
expect(body.emit_event_type).toBe('TaskCompleted')
})
})
describe('listReactions', () => {
it('GETs /reactions', async () => {
await initClient()
mockOk({ reactions: [] })
const result = await client.listReactions()
expect(Array.isArray(result)).toBe(true)
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/reactions')
})
})
describe('deleteReaction', () => {
it('DELETEs /reactions/:id with numeric id', async () => {
await initClient()
mockOk({ ok: true })
const result = await client.deleteReaction(3)
expect(result.ok).toBe(true)
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://api.example.com/reactions/3')
expect(opts.method).toBe('DELETE')
})
})
// ─── health ──────────────────────────────────────────────────────────────────
describe('health', () => {
it('GETs /health', async () => {
await initClient()
mockOk({ status: 'ok', version: '2.4.0' })
const result = await client.health()
expect(result.status).toBe('ok')
expect(result.version).toBe('2.4.0')
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/health')
})
})
// ─── error handling ──────────────────────────────────────────────────────────
describe('error handling', () => {
it('throws on 401', async () => {
await initClient()
mockFail(401, 'Unauthorized')
await expect(client.listObjectDefs()).rejects.toThrow('Authentication failed. Check your token.')
})
it('throws API error message on non-2xx', async () => {
await initClient()
mockFail(400, 'Invalid request')
await expect(client.createObjectDef('bad')).rejects.toThrow('Invalid request')
})
it('throws connection error on fetch failure', async () => {
await initClient()
mockFetch.mockRejectedValue(new Error('fetch failed: connection refused'))
await expect(client.health()).rejects.toThrow('Cannot reach OGraph API at https://api.example.com')
})
})
})

View File

@ -0,0 +1,72 @@
// Configuration tests
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { randomUUID } from 'node:crypto'
import { loadConfig, saveConfig, setConfigValue, getConfigValue } from '../src/config.js'
let testDir: string
beforeEach(() => {
testDir = join(tmpdir(), `ograph-test-${randomUUID()}`)
process.env.OGRAPH_CONFIG_DIR = testDir
})
afterEach(async () => {
delete process.env.OGRAPH_CONFIG_DIR
await rm(testDir, { recursive: true, force: true })
})
describe('config management', () => {
it('should return empty config when file does not exist', async () => {
const config = await loadConfig()
expect(config).toEqual({})
})
it('should save and load config correctly', async () => {
const testConfig = {
endpoint: 'https://test.example.com',
token: 'test-token-123',
}
await saveConfig(testConfig)
const loadedConfig = await loadConfig()
expect(loadedConfig).toEqual(testConfig)
})
it('should set individual config values', async () => {
await setConfigValue('endpoint', 'https://api.example.com')
await setConfigValue('token', 'secret-token')
const config = await loadConfig()
expect(config.endpoint).toBe('https://api.example.com')
expect(config.token).toBe('secret-token')
})
it('should get individual config values', async () => {
await saveConfig({
endpoint: 'https://get.example.com',
token: 'get-token-456',
})
const endpoint = await getConfigValue('endpoint')
const token = await getConfigValue('token')
const missing = await getConfigValue('nonexistent' as any)
expect(endpoint).toBe('https://get.example.com')
expect(token).toBe('get-token-456')
expect(missing).toBeUndefined()
})
it('should handle malformed config file gracefully', async () => {
const { writeFile, mkdir } = await import('node:fs/promises')
await mkdir(testDir, { recursive: true })
await writeFile(join(testDir, 'config.json'), '{bad json!!!}', 'utf-8')
const config = await loadConfig()
expect(config).toEqual({})
})
})

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"allowJs": true,
"sourceMap": false,
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View File

@ -0,0 +1,44 @@
-- Drop all v1 tables
DROP TABLE IF EXISTS subscriptions;
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS edges;
DROP TABLE IF EXISTS objects;
DROP TABLE IF EXISTS relation_types;
DROP TABLE IF EXISTS object_types;
-- v2 Schema
CREATE TABLE types (
name TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK(kind IN ('obj', 'evt')),
label TEXT NOT NULL
);
CREATE TABLE relation_types (
name TEXT PRIMARY KEY,
from_kind TEXT NOT NULL CHECK(from_kind IN ('obj', 'evt')),
to_kind TEXT NOT NULL CHECK(to_kind IN ('obj', 'evt')),
inverse TEXT NOT NULL,
UNIQUE(inverse)
);
CREATE TABLE nodes (
oid TEXT PRIMARY KEY,
type TEXT NOT NULL REFERENCES types(name),
data TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_nodes_type ON nodes(type);
CREATE INDEX idx_nodes_created ON nodes(created_at);
CREATE TABLE edges (
oid TEXT PRIMARY KEY,
from_oid TEXT NOT NULL REFERENCES nodes(oid),
rel TEXT NOT NULL REFERENCES relation_types(name),
to_oid TEXT NOT NULL REFERENCES nodes(oid),
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
UNIQUE(from_oid, rel, to_oid)
);
CREATE INDEX idx_edges_from ON edges(from_oid);
CREATE INDEX idx_edges_to ON edges(to_oid);
CREATE INDEX idx_edges_rel ON edges(rel);

View File

@ -0,0 +1,30 @@
CREATE TABLE reducers (
name TEXT PRIMARY KEY,
driven_by TEXT NOT NULL,
params TEXT NOT NULL,
filter TEXT NOT NULL,
expression TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'fold' CHECK(mode IN ('fold', 'latest', 'window')),
window_size INTEGER,
initial_value TEXT
);
CREATE TABLE projections (
reducer TEXT NOT NULL REFERENCES reducers(name),
params TEXT NOT NULL,
params_hash TEXT NOT NULL,
value TEXT,
updated_by TEXT,
updated_at INTEGER,
live INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (reducer, params_hash)
);
CREATE TABLE event_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
evt_oid TEXT NOT NULL,
processed_reducers TEXT,
processed_reactions TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_event_log_created ON event_log(created_at);

View File

@ -0,0 +1,15 @@
-- 重建 reducers 表(SQLite 不支持 DROP COLUMN)
CREATE TABLE reducers_new (
name TEXT PRIMARY KEY,
driven_by TEXT NOT NULL,
params TEXT NOT NULL,
filter TEXT NOT NULL,
expression TEXT NOT NULL,
initial_value TEXT
);
INSERT INTO reducers_new (name, driven_by, params, filter, expression, initial_value)
SELECT name, driven_by, params, filter, expression, initial_value FROM reducers;
DROP TABLE reducers;
ALTER TABLE reducers_new RENAME TO reducers;

View File

@ -0,0 +1,10 @@
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reducer TEXT NOT NULL REFERENCES reducers(name),
params_hash TEXT,
condition TEXT,
worker_url TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_reactions_reducer ON reactions(reducer);

View File

@ -0,0 +1,65 @@
-- Drop v2.0 tables
DROP TABLE IF EXISTS reactions;
DROP TABLE IF EXISTS projections;
DROP TABLE IF EXISTS reducers;
DROP TABLE IF EXISTS event_log;
DROP TABLE IF EXISTS edges;
DROP TABLE IF EXISTS nodes;
DROP TABLE IF EXISTS relation_types;
DROP TABLE IF EXISTS types;
-- v2.1 Schema
CREATE TABLE event_types (
name TEXT PRIMARY KEY,
label TEXT NOT NULL,
schema TEXT NOT NULL
);
CREATE TABLE events (
oid TEXT PRIMARY KEY,
type TEXT NOT NULL REFERENCES event_types(name),
payload TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_events_type ON events(type);
CREATE INDEX idx_events_created ON events(created_at);
CREATE TABLE event_refs (
event_oid TEXT NOT NULL REFERENCES events(oid),
property TEXT NOT NULL,
ref_oid TEXT NOT NULL,
PRIMARY KEY (event_oid, property)
);
CREATE INDEX idx_event_refs_obj ON event_refs(ref_oid);
CREATE TABLE reducers (
name TEXT PRIMARY KEY,
driven_by TEXT NOT NULL,
params TEXT NOT NULL,
filter TEXT NOT NULL,
expression TEXT NOT NULL,
initial_value TEXT
);
CREATE TABLE projections (
reducer TEXT NOT NULL REFERENCES reducers(name),
params TEXT NOT NULL,
params_hash TEXT NOT NULL,
value TEXT,
updated_by TEXT,
updated_at INTEGER,
live INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (reducer, params_hash)
);
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reducer TEXT NOT NULL,
params_hash TEXT,
condition TEXT,
worker_url TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_reactions_reducer ON reactions(reducer);

View File

@ -0,0 +1,82 @@
-- OGraph v2.2 Schema
-- RFC-016 v2.2: Event, Projection, Reaction (8 tables)
-- Drop all v2.1 tables
DROP TABLE IF EXISTS projection_deps;
DROP TABLE IF EXISTS projections;
DROP TABLE IF EXISTS projection_defs;
DROP TABLE IF EXISTS event_refs;
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS event_defs;
DROP TABLE IF EXISTS objects;
DROP TABLE IF EXISTS object_defs;
DROP TABLE IF EXISTS reactions;
-- ============================================
-- Definition Layer (3 tables)
-- ============================================
CREATE TABLE object_defs (
name TEXT PRIMARY KEY
);
CREATE TABLE event_defs (
name TEXT PRIMARY KEY,
schema TEXT NOT NULL -- JSON: { properties: { ... } }
);
CREATE TABLE projection_defs (
name TEXT PRIMARY KEY,
driven_by TEXT NOT NULL, -- JSON: ["assigned", "reassigned"]
params TEXT NOT NULL, -- JSON: input schema, e.g. {"task": {"type": "ref"}}
filter TEXT NOT NULL, -- JSONata: $event 精筛
expression TEXT NOT NULL, -- JSONata: ($state, $events) → new_state
initial_value TEXT -- JSON: 初始值
);
-- ============================================
-- Instance Layer (5 tables)
-- ============================================
CREATE TABLE objects (
id TEXT PRIMARY KEY,
type TEXT NOT NULL REFERENCES object_defs(name),
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL REFERENCES event_defs(name),
payload TEXT NOT NULL, -- JSON: flat key-value
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE event_refs (
event_id TEXT NOT NULL REFERENCES events(id),
property TEXT NOT NULL,
ref_id TEXT NOT NULL REFERENCES objects(id),
PRIMARY KEY (event_id, property)
);
CREATE INDEX idx_event_refs_obj ON event_refs(ref_id);
CREATE TABLE projections (
def TEXT NOT NULL REFERENCES projection_defs(name),
params_hash TEXT NOT NULL,
params TEXT NOT NULL, -- JSON: 实际参数值
value TEXT, -- JSON: 当前值
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER,
PRIMARY KEY (def, params_hash)
);
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
projection_def TEXT NOT NULL REFERENCES projection_defs(name),
params_hash TEXT NOT NULL,
params TEXT NOT NULL, -- JSON: 监听哪个 projection 实例的参数
webhook_url TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_reactions_projection ON reactions(projection_def, params_hash);

View File

@ -0,0 +1,104 @@
-- OGraph v2.3 Schema
-- Immutable definitions (hash ID) + Mutable name pointers
-- Drop all v2.2 tables
DROP TABLE IF EXISTS reactions;
DROP TABLE IF EXISTS projections;
DROP TABLE IF EXISTS event_refs;
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS objects;
DROP TABLE IF EXISTS projection_defs;
DROP TABLE IF EXISTS event_defs;
DROP TABLE IF EXISTS object_defs;
-- ============================================
-- Definition Versions (immutable, hash ID)
-- ============================================
CREATE TABLE object_def_versions (
hash TEXT PRIMARY KEY,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE event_def_versions (
hash TEXT PRIMARY KEY,
schema TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE projection_def_versions (
hash TEXT PRIMARY KEY,
driven_by TEXT NOT NULL, -- JSON: [event_def_hash, ...]
params TEXT NOT NULL,
filter TEXT NOT NULL,
expression TEXT NOT NULL,
initial_value TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
-- ============================================
-- Name Pointers (mutable)
-- ============================================
CREATE TABLE object_def_names (
name TEXT PRIMARY KEY,
current_hash TEXT NOT NULL REFERENCES object_def_versions(hash),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE event_def_names (
name TEXT PRIMARY KEY,
current_hash TEXT NOT NULL REFERENCES event_def_versions(hash),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE projection_def_names (
name TEXT PRIMARY KEY,
current_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
-- ============================================
-- Instances
-- ============================================
CREATE TABLE objects (
id TEXT PRIMARY KEY,
type_hash TEXT NOT NULL REFERENCES object_def_versions(hash),
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE events (
id TEXT PRIMARY KEY,
type_hash TEXT NOT NULL REFERENCES event_def_versions(hash),
payload TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE event_refs (
event_id TEXT NOT NULL REFERENCES events(id),
property TEXT NOT NULL,
ref_id TEXT NOT NULL REFERENCES objects(id),
PRIMARY KEY (event_id, property)
);
CREATE INDEX idx_event_refs_obj ON event_refs(ref_id);
CREATE TABLE projections (
def_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
params_hash TEXT NOT NULL,
params TEXT NOT NULL,
value TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER,
PRIMARY KEY (def_hash, params_hash)
);
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
projection_def_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
params_hash TEXT NOT NULL,
params TEXT NOT NULL,
webhook_url TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_reactions_projection ON reactions(projection_def_hash, params_hash);

View File

@ -0,0 +1,113 @@
-- OGraph v2.4 Schema
-- object_defs 回归单表(去掉 object_def_versions + object_def_names)
-- version 表加 parent_hash(版本链)和 name(反查)
-- projection_def 加 value_schema NOT NULL + initial_value NOT NULL
-- projections.value NOT NULL
-- objects.type 直接存名字(不存 hash)
-- Drop all v2.3 tables
DROP TABLE IF EXISTS reactions;
DROP TABLE IF EXISTS projections;
DROP TABLE IF EXISTS event_refs;
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS objects;
DROP TABLE IF EXISTS projection_def_names;
DROP TABLE IF EXISTS projection_def_versions;
DROP TABLE IF EXISTS event_def_names;
DROP TABLE IF EXISTS event_def_versions;
DROP TABLE IF EXISTS object_def_names;
DROP TABLE IF EXISTS object_def_versions;
-- ============================================
-- Object 定义(无版本,纯名字)
-- ============================================
CREATE TABLE object_defs (
name TEXT PRIMARY KEY
);
-- ============================================
-- Event 定义(版本链 + 名字指针)
-- ============================================
CREATE TABLE event_def_versions (
hash TEXT PRIMARY KEY,
name TEXT NOT NULL, -- 属于哪个定义(反查)
parent_hash TEXT REFERENCES event_def_versions(hash), -- 前一版本,首版 NULL
schema TEXT NOT NULL, -- JSON: { properties: { ... } }
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE event_def_names (
name TEXT PRIMARY KEY,
current_hash TEXT NOT NULL REFERENCES event_def_versions(hash),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
-- ============================================
-- Projection 定义(版本链 + 名字指针 + value schema)
-- ============================================
CREATE TABLE projection_def_versions (
hash TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_hash TEXT REFERENCES projection_def_versions(hash),
driven_by TEXT NOT NULL, -- JSON: [event_def_hash, ...]
params TEXT NOT NULL, -- JSON: input schema
filter TEXT NOT NULL, -- JSONata
expression TEXT NOT NULL, -- JSONata
value_schema TEXT NOT NULL, -- JSON: { type: "number" } 等
initial_value TEXT NOT NULL, -- JSON: 必须符合 value_schema
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE projection_def_names (
name TEXT PRIMARY KEY,
current_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
-- ============================================
-- 实例表
-- ============================================
CREATE TABLE objects (
id TEXT PRIMARY KEY,
type TEXT NOT NULL REFERENCES object_defs(name),
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE events (
id TEXT PRIMARY KEY,
type_hash TEXT NOT NULL REFERENCES event_def_versions(hash),
payload TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE event_refs (
event_id TEXT NOT NULL REFERENCES events(id),
property TEXT NOT NULL,
ref_id TEXT NOT NULL REFERENCES objects(id),
PRIMARY KEY (event_id, property)
);
CREATE INDEX idx_event_refs_obj ON event_refs(ref_id);
CREATE TABLE projections (
def_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
params_hash TEXT NOT NULL,
params TEXT NOT NULL,
value TEXT NOT NULL, -- NOT NULL,至少是 initial_value
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER,
PRIMARY KEY (def_hash, params_hash)
);
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
projection_def_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
params_hash TEXT NOT NULL,
params TEXT NOT NULL,
webhook_url TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_reactions_projection ON reactions(projection_def_hash, params_hash);

View File

@ -0,0 +1,46 @@
-- Replace filter (JSONata) with bindings (structured ref matching)
-- Clean slate for projection_def_versions, projection_def_names, projections, reactions
DROP TABLE IF EXISTS reactions;
DROP TABLE IF EXISTS projections;
DROP TABLE IF EXISTS projection_def_names;
DROP TABLE IF EXISTS projection_def_versions;
CREATE TABLE projection_def_versions (
hash TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_hash TEXT REFERENCES projection_def_versions(hash),
driven_by TEXT NOT NULL,
params TEXT NOT NULL,
bindings TEXT NOT NULL, -- JSON: { property: "$param" | "literal_id" }
expression TEXT NOT NULL,
value_schema TEXT NOT NULL,
initial_value TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE projection_def_names (
name TEXT PRIMARY KEY,
current_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE projections (
def_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
params_hash TEXT NOT NULL,
params TEXT NOT NULL,
value TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER,
PRIMARY KEY (def_hash, params_hash)
);
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
projection_def_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
params_hash TEXT NOT NULL,
params TEXT NOT NULL,
webhook_url TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_reactions_projection ON reactions(projection_def_hash, params_hash);

View File

@ -0,0 +1,54 @@
-- Drop old projection-related tables (clean slate, pre-launch)
DROP TABLE IF EXISTS reactions;
DROP TABLE IF EXISTS projections;
DROP TABLE IF EXISTS projection_def_names;
DROP TABLE IF EXISTS projection_def_versions;
-- Recreate projection_def_versions WITHOUT driven_by, bindings, expression
CREATE TABLE projection_def_versions (
hash TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_hash TEXT REFERENCES projection_def_versions(hash),
params TEXT NOT NULL,
value_schema TEXT NOT NULL,
initial_value TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
-- New: per-source bindings + expression
CREATE TABLE projection_def_sources (
projection_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
event_def_hash TEXT NOT NULL,
bindings TEXT NOT NULL,
expression TEXT NOT NULL,
PRIMARY KEY (projection_hash, event_def_hash)
);
CREATE INDEX idx_pds_event ON projection_def_sources(event_def_hash);
-- Recreate name pointers
CREATE TABLE projection_def_names (
name TEXT PRIMARY KEY,
current_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
-- Recreate instance tables
CREATE TABLE projections (
def_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
params_hash TEXT NOT NULL,
params TEXT NOT NULL,
value TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER,
PRIMARY KEY (def_hash, params_hash)
);
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
projection_def_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
params_hash TEXT NOT NULL,
params TEXT NOT NULL,
webhook_url TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_reactions_projection ON reactions(projection_def_hash, params_hash);

View File

@ -0,0 +1,17 @@
-- Add action type support to reactions (Phase 2: emit_event)
-- Pre-launch: drop and recreate reactions table
DROP TABLE IF EXISTS reactions;
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
projection_def_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
params_hash TEXT NOT NULL,
params TEXT NOT NULL,
action TEXT NOT NULL DEFAULT 'webhook', -- 'webhook' | 'emit_event'
webhook_url TEXT, -- required when action = 'webhook'
emit_event_type TEXT, -- required when action = 'emit_event'
emit_payload_template TEXT, -- JSONata: optional template for emit_event
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_reactions_projection ON reactions(projection_def_hash, params_hash);

View File

@ -0,0 +1,52 @@
-- Migration 0017: Unify instance IDs to INTEGER AUTOINCREMENT
-- Pre-launch clean slate: drop and recreate all instance + reaction tables
DROP TABLE IF EXISTS reactions;
DROP TABLE IF EXISTS projections;
DROP TABLE IF EXISTS event_refs;
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS objects;
CREATE TABLE objects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL REFERENCES object_defs(name),
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type_hash TEXT NOT NULL REFERENCES event_def_versions(hash),
payload TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE TABLE event_refs (
event_id INTEGER NOT NULL REFERENCES events(id),
property TEXT NOT NULL,
ref_id INTEGER NOT NULL REFERENCES objects(id),
PRIMARY KEY (event_id, property)
);
CREATE INDEX idx_event_refs_obj ON event_refs(ref_id);
CREATE TABLE projections (
def_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
params_hash TEXT NOT NULL,
params TEXT NOT NULL,
value TEXT NOT NULL,
last_event_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
PRIMARY KEY (def_hash, params_hash)
);
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
projection_def_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
params_hash TEXT NOT NULL,
params TEXT NOT NULL,
action TEXT NOT NULL DEFAULT 'webhook',
webhook_url TEXT,
emit_event_type TEXT,
emit_payload_template TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_reactions_projection ON reactions(projection_def_hash, params_hash);

View File

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS reaction_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reaction_id INTEGER NOT NULL,
trigger_event_id INTEGER NOT NULL,
projection_def TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
action TEXT NOT NULL,
status TEXT NOT NULL,
handler_output TEXT,
duration_ms INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX IF NOT EXISTS idx_rlog_reaction ON reaction_logs(reaction_id);
CREATE INDEX IF NOT EXISTS idx_rlog_event ON reaction_logs(trigger_event_id);

View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'ingest',
allowed_events TEXT NOT NULL DEFAULT '[]',
rate_limit INTEGER DEFAULT 100,
last_used_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);

View File

@ -0,0 +1,42 @@
-- Add handler_code column to reactions table
-- Pre-launch: drop and recreate
DROP TABLE IF EXISTS reaction_logs;
DROP TABLE IF EXISTS reactions;
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
projection_def_hash TEXT NOT NULL REFERENCES projection_def_versions(hash),
params_hash TEXT NOT NULL,
params TEXT NOT NULL,
action TEXT NOT NULL DEFAULT 'webhook',
webhook_url TEXT,
emit_event_type TEXT,
emit_payload_template TEXT,
handler_code TEXT,
handler_timeout_ms INTEGER DEFAULT 5000,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_reactions_projection ON reactions(projection_def_hash, params_hash);
CREATE TABLE reaction_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reaction_id INTEGER NOT NULL,
trigger_event_id INTEGER NOT NULL,
projection_def TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
action TEXT NOT NULL,
status TEXT NOT NULL,
handler_output TEXT,
duration_ms INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX idx_rlog_reaction ON reaction_logs(reaction_id);
CREATE INDEX idx_rlog_event ON reaction_logs(trigger_event_id);
CREATE TABLE IF NOT EXISTS reaction_kv (
reaction_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (reaction_id, key)
);

View File

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS request_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
method TEXT NOT NULL,
path TEXT NOT NULL,
api_key_id INTEGER,
api_key_name TEXT,
status_code INTEGER NOT NULL,
error TEXT,
duration_ms INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE INDEX IF NOT EXISTS idx_reqlog_key ON request_logs(api_key_id);
CREATE INDEX IF NOT EXISTS idx_reqlog_created ON request_logs(created_at);

View File

@ -0,0 +1,22 @@
{
"name": "@uncaged/ograph",
"version": "0.1.0",
"description": "OGraph \u2014 Event Sourcing + Projection + Reaction engine on Cloudflare Workers",
"type": "module",
"main": "src/index.ts",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run"
},
"dependencies": {
"hono": "^4.0.0",
"jsonata": "^2.1.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260403.0",
"typescript": "^6.0.2",
"vitest": "^4.1.2",
"wrangler": "^4.0.0"
}
}

View File

@ -0,0 +1,21 @@
import { createMiddleware } from 'hono/factory'
type Env = {
DB: D1Database
API_TOKEN: string
}
export const bearerAuth = (expectedToken: string) =>
createMiddleware<{ Bindings: Env }>(async (c, next) => {
const header = c.req.header('Authorization')
if (!header?.startsWith('Bearer ')) {
return c.json({ error: 'Missing or invalid Authorization header' }, 401)
}
const token = header.slice(7)
if (token !== expectedToken) {
return c.json({ error: 'Invalid token' }, 401)
}
await next()
})

File diff suppressed because it is too large Load Diff

4
packages/engine/src/html.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.html' {
const content: string
export default content
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,483 @@
/**
* OGraph Gateway + Engine (unified Worker)
*
* Route classification:
* - PUBLIC: GET /health no auth required
* - EXTERNAL: POST /events API key (Bearer) or API_TOKEN
* - ADMIN: Everything else API_TOKEN only
* - INTERNAL: Reaction handler execution engine internal only
*/
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { bearerAuth } from './auth'
import UI_HTML from './ui.html'
import {
createObjectDef,
listObjectDefs,
createObject,
getObject,
listObjects,
createEventDef,
listEventDefs,
createEvent,
getEvent,
findEventsByRef,
createProjectionDef,
listProjectionDefs,
getProjection,
createReaction,
listReactions,
deleteReaction,
listReactionLogs,
createApiKey,
listApiKeys,
deleteApiKey,
validateApiKey,
} from './engine'
import type {
CreateObjectDefRequest,
CreateObjectRequest,
CreateEventDefRequest,
CreateEventRequest,
CreateProjectionDefRequest,
CreateReactionRequest,
CreateApiKeyRequest,
ReactionPayload,
} from './types'
type Bindings = {
DB: D1Database
API_TOKEN: string
}
type Variables = {
apiKeyId: number | null
apiKeyName: string | null
}
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()
app.use('*', cors())
app.use('*', async (c, next) => {
const start = Date.now()
await next()
const duration = Date.now() - start
const path = new URL(c.req.url).pathname
if (path === '/health' || path.startsWith('/ui')) return
try {
c.executionCtx.waitUntil(
c.env.DB.prepare(
'INSERT INTO request_logs (method, path, api_key_id, api_key_name, status_code, error, duration_ms, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
)
.bind(
c.req.method,
path,
c.get('apiKeyId') || null,
c.get('apiKeyName') || null,
c.res.status,
c.res.status >= 400
? await c.res
.clone()
.text()
.catch(() => null)
: null,
duration,
Date.now(),
)
.run(),
)
} catch {
// executionCtx not available in test, skip
}
})
// ============================================
// UI (no auth, served before auth middleware)
// ============================================
app.get('/ui', (c) => {
return c.html(UI_HTML)
})
app.get('/ui/*', (c) => {
return c.html(UI_HTML)
})
// ============================================
// Health
// ============================================
app.get('/health', (c) => {
return c.json({ status: 'ok', version: '2.4.0' })
})
// Auth middleware for all routes except health, ui, and POST /events (which has its own dual auth)
app.use('*', async (c, next) => {
if (c.req.path === '/health' || c.req.path.startsWith('/ui')) return next()
if (c.req.method === 'POST' && c.req.path === '/events') return next()
return bearerAuth(c.env.API_TOKEN)(c, next)
})
// ============================================
// Object Defs
// ============================================
app.post('/object-defs', async (c) => {
try {
const body = await c.req.json<CreateObjectDefRequest>()
if (!body.name) return c.json({ error: 'Missing name' }, 400)
const result = await createObjectDef(c.env.DB, body.name)
return c.json(result, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
}
})
app.get('/object-defs', async (c) => {
const defs = await listObjectDefs(c.env.DB)
return c.json({ object_defs: defs })
})
// ============================================
// Objects
// ============================================
app.post('/objects', async (c) => {
try {
const body = await c.req.json<CreateObjectRequest>()
if (!body.type) return c.json({ error: 'Missing type' }, 400)
const obj = await createObject(c.env.DB, body.type)
return c.json(obj, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
}
})
app.get('/objects/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10)
if (isNaN(id)) return c.json({ error: 'Invalid id' }, 400)
const obj = await getObject(c.env.DB, id)
if (!obj) return c.json({ error: 'Not found' }, 404)
return c.json(obj)
})
app.get('/objects', async (c) => {
const type = c.req.query('type')
const limitParam = c.req.query('limit')
const offsetParam = c.req.query('offset')
const limit = Math.min(parseInt(limitParam || '50', 10), 200)
const offset = parseInt(offsetParam || '0', 10)
const result = await listObjects(c.env.DB, type, limit, offset)
return c.json(result)
})
// ============================================
// Event Defs
// ============================================
app.post('/event-defs', async (c) => {
try {
const body = await c.req.json<CreateEventDefRequest>()
if (!body.name || !body.schema) return c.json({ error: 'Missing name or schema' }, 400)
const result = await createEventDef(c.env.DB, body.name, body.schema)
return c.json(result, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
}
})
app.get('/event-defs', async (c) => {
const defs = await listEventDefs(c.env.DB)
return c.json({ event_defs: defs })
})
// ============================================
// Events
// ============================================
app.post('/events', async (c) => {
// Dual auth: check if this request already passed API_TOKEN auth.
// If not (i.e., Bearer token is not the API_TOKEN), validate as API key.
const authHeader = c.req.header('Authorization')
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!bearerToken) {
return c.json({ error: 'Missing or invalid Authorization header' }, 401)
}
let body: CreateEventRequest
try {
body = await c.req.json<CreateEventRequest>()
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
if (!body.type || !body.payload) return c.json({ error: 'Missing type or payload' }, 400)
// If the token is not API_TOKEN, treat it as an API key
if (bearerToken !== c.env.API_TOKEN) {
const result = await validateApiKey(c.env.DB, bearerToken, body.type)
if (!result.valid) {
if (result.error === 'event_not_allowed') {
return c.json({ error: 'Event type not allowed for this API key' }, 403)
}
return c.json({ error: 'Invalid API key' }, 401)
}
if (result.apiKey) {
c.set('apiKeyId', result.apiKey.id)
c.set('apiKeyName', result.apiKey.name)
}
}
try {
const { event, reactions_fired, reaction_results } = await createEvent(c.env.DB, body.type, body.payload)
// Fire-and-forget webhook POSTs for reactions (if any)
if (reaction_results.length > 0) {
try {
c.executionCtx.waitUntil(fireReactionWebhooks(c.env.DB, reaction_results))
} catch {
// executionCtx not available in test environment, skip webhook firing
}
}
return c.json({ event, reactions_fired, reaction_results }, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
}
})
app.get('/events/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10)
if (isNaN(id)) return c.json({ error: 'Invalid id' }, 400)
const event = await getEvent(c.env.DB, id)
if (!event) return c.json({ error: 'Not found' }, 404)
return c.json(event)
})
app.get('/events', async (c) => {
const refParam = c.req.query('ref')
const refId = refParam ? parseInt(refParam, 10) : undefined
const limitParam = c.req.query('limit')
const offsetParam = c.req.query('offset')
const limit = Math.min(parseInt(limitParam || '50', 10), 200)
const offset = parseInt(offsetParam || '0', 10)
const result = await findEventsByRef(c.env.DB, refId, limit, offset)
return c.json(result)
})
// ============================================
// Projection Defs
// ============================================
app.post('/projection-defs', async (c) => {
try {
const body = await c.req.json<CreateProjectionDefRequest>()
if (!body.name || !body.value_schema || body.initial_value === undefined) {
return c.json({ error: 'Missing name, value_schema, or initial_value' }, 400)
}
if (!body.sources || !Array.isArray(body.sources) || body.sources.length === 0) {
return c.json({ error: 'Missing or empty sources array' }, 400)
}
const result = await createProjectionDef(
c.env.DB,
body.name,
body.sources,
body.params,
body.value_schema,
body.initial_value,
)
return c.json(result, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
}
})
app.get('/projection-defs', async (c) => {
const defs = await listProjectionDefs(c.env.DB)
return c.json({ projection_defs: defs })
})
// ============================================
// Projections
// ============================================
app.get('/projections/:name', async (c) => {
try {
const name = c.req.param('name')
const rawParams = c.req.queries()
const params: Record<string, any> = {}
for (const [key, values] of Object.entries(rawParams)) {
params[key] = values[0] // take first value
}
const value = await getProjection(c.env.DB, name, params)
return c.json({ value })
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
}
})
// ============================================
// Reactions
// ============================================
app.post('/reactions', async (c) => {
try {
const body = await c.req.json<CreateReactionRequest>()
const action = body.action || 'webhook'
if (!body.projection_def || !body.params) {
return c.json({ error: 'Missing projection_def or params' }, 400)
}
if (action === 'webhook' && !body.webhook_url) {
return c.json({ error: 'webhook_url is required when action is webhook' }, 400)
}
if (action === 'emit_event' && !body.emit_event_type) {
return c.json({ error: 'emit_event_type is required when action is emit_event' }, 400)
}
if (action === 'handler' && !body.handler_code) {
return c.json({ error: 'handler_code is required when action is handler' }, 400)
}
const reaction = await createReaction(c.env.DB, body.projection_def, body.params, {
action,
webhook_url: body.webhook_url,
emit_event_type: body.emit_event_type,
emit_payload_template: body.emit_payload_template,
handler_code: body.handler_code,
handler_timeout_ms: body.handler_timeout_ms,
})
return c.json(reaction, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
}
})
app.get('/reactions', async (c) => {
const limitParam = c.req.query('limit')
const offsetParam = c.req.query('offset')
const limit = Math.min(parseInt(limitParam || '50', 10), 200)
const offset = parseInt(offsetParam || '0', 10)
const result = await listReactions(c.env.DB, limit, offset)
return c.json(result)
})
app.delete('/reactions/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10)
await deleteReaction(c.env.DB, id)
return c.json({ deleted: id })
})
// ============================================
// Reaction Logs
// ============================================
app.get('/reaction-logs', async (c) => {
const limit = parseInt(c.req.query('limit') || '50', 10)
const offset = parseInt(c.req.query('offset') || '0', 10)
const reactionId = c.req.query('reaction_id') ? parseInt(c.req.query('reaction_id')!, 10) : undefined
const result = await listReactionLogs(c.env.DB, limit, offset, reactionId)
return c.json(result)
})
// ============================================
// Request Logs
// ============================================
app.get('/request-logs', async (c) => {
const db = c.env.DB
const limit = parseInt(c.req.query('limit') || '50', 10)
const offset = parseInt(c.req.query('offset') || '0', 10)
const apiKeyId = c.req.query('api_key_id') ? parseInt(c.req.query('api_key_id')!, 10) : undefined
let query = 'SELECT * FROM request_logs'
const binds: any[] = []
if (apiKeyId !== undefined) {
query += ' WHERE api_key_id = ?'
binds.push(apiKeyId)
}
query += ' ORDER BY id DESC LIMIT ? OFFSET ?'
binds.push(limit, offset)
let countQuery = 'SELECT COUNT(*) as total FROM request_logs'
if (apiKeyId !== undefined) {
countQuery += ' WHERE api_key_id = ?'
}
const [rows, countRow] = await Promise.all([
db
.prepare(query)
.bind(...binds)
.all(),
db
.prepare(countQuery)
.bind(...(apiKeyId !== undefined ? [apiKeyId] : []))
.first<{ total: number }>(),
])
return c.json({ logs: rows.results, total: countRow?.total || 0 })
})
// ============================================
// API Keys
// ============================================
app.post('/api-keys', async (c) => {
try {
const body = await c.req.json<CreateApiKeyRequest>()
if (!body.name) return c.json({ error: 'Missing name' }, 400)
const result = await createApiKey(c.env.DB, body.name, body.role, body.allowed_events, body.rate_limit)
return c.json(result, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
}
})
app.get('/api-keys', async (c) => {
const limitParam = c.req.query('limit')
const offsetParam = c.req.query('offset')
const limit = Math.min(parseInt(limitParam || '50', 10), 200)
const offset = parseInt(offsetParam || '0', 10)
const result = await listApiKeys(c.env.DB, limit, offset)
return c.json(result)
})
app.delete('/api-keys/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10)
await deleteApiKey(c.env.DB, id)
return c.json({ deleted: id })
})
// ============================================
// Helper: Fire Reaction Webhooks
// ============================================
async function fireReactionWebhooks(db: D1Database, payloads: ReactionPayload[]): Promise<void> {
for (const payload of payloads) {
try {
// Look up webhook URL for this reaction
const reaction = await db
.prepare('SELECT webhook_url FROM reactions WHERE id = ?')
.bind(payload.reaction_id)
.first<{ webhook_url: string }>()
if (!reaction) continue
await fetch(reaction.webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
} catch {
// Ignore webhook errors
}
}
}
export default app

View File

@ -0,0 +1,25 @@
import type { ReactionPayload } from './types'
/**
* Async reaction executor fire-and-forget HTTP POST to worker_url
*/
export async function executeReaction(payload: ReactionPayload): Promise<void> {
try {
await fetch(payload.worker_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reaction_id: payload.reaction_id,
reducer: payload.reducer,
params: payload.params,
old_value: payload.old_value,
new_value: payload.new_value,
evt_oid: payload.evt_oid,
config: payload.config,
}),
})
} catch {
// Fire and forget — log but don't throw
console.error(`Reaction ${payload.reaction_id} failed: ${payload.worker_url}`)
}
}

View File

@ -0,0 +1,240 @@
// OGraph v2.4 Types
// object_defs 回归单表(去掉 versions + names)
// version 表加 parent_hash(版本链)和 name(反查)
// projection_def 加 value_schema NOT NULL + initial_value NOT NULL
// objects.type 直接存名字(不存 hash)
// ============================================
// Definition Layer
// ============================================
export interface PropertyDef {
type: 'ref' | 'string' | 'number' | 'boolean'
object_type?: string | string[] // for ref type: polymorphic support
}
// Event & Projection Definition Versions (immutable, with version chain)
export interface EventDefVersion {
hash: string
name: string // 属于哪个定义(反查)
parent_hash: string | null // 前一版本,首版 NULL
schema: {
properties: Record<string, PropertyDef>
}
created_at: number
}
export interface ProjectionDefSource {
event_def_hash: string
bindings: Record<string, string>
expression: string
}
export interface ProjectionDefVersion {
hash: string
name: string
parent_hash: string | null
params: Record<string, { type: 'ref'; object_type?: string }>
sources: ProjectionDefSource[]
value_schema: { type: string } // e.g., { type: "number" } or { type: "ref" }
initial_value: any // NOT NULL
created_at: number
}
// Name pointers (mutable)
export interface EventDefName {
name: string
current_hash: string
updated_at: number
}
export interface ProjectionDefName {
name: string
current_hash: string
updated_at: number
}
// Combined view (for API responses)
export interface ObjectDef {
name: string
}
export interface EventDef {
name: string
hash: string
parent_hash: string | null
schema: {
properties: Record<string, PropertyDef>
}
}
export interface ProjectionDef {
name: string
hash: string
parent_hash: string | null
params: Record<string, { type: 'ref'; object_type?: string }>
sources: ProjectionDefSource[]
value_schema: { type: string }
initial_value: any
}
// ============================================
// Instance Layer
// ============================================
export interface Object {
id: number
type: string // 直接存名字(不存 hash)
created_at: number
}
export interface Event {
id: number
type_hash: string
payload: Record<string, any>
created_at: number
}
export interface EventRef {
event_id: number
property: string
ref_id: number
}
export interface Projection {
def_hash: string
params_hash: string
params: Record<string, any>
value: any // NOT NULL
last_event_id: number
created_at: number
}
export interface Reaction {
id: number
projection_def_hash: string
params_hash: string
params: Record<string, any>
action: 'webhook' | 'emit_event' | 'handler'
webhook_url?: string
emit_event_type?: string
emit_payload_template?: string // JSONata: (old_value, new_value, params, event) → payload
handler_code?: string
handler_timeout_ms?: number
created_at: number
}
// ============================================
// Runtime Context
// ============================================
export interface EventContext {
id: number
type: string
timestamp: number
[key: string]: any
}
export interface ReactionPayload {
reaction_id: number
projection_def: string
params: Record<string, any>
old_value: any
new_value: any
event: EventContext
timestamp: number
log_id?: number
}
export interface ReactionLog {
id: number
reaction_id: number
trigger_event_id: number
projection_def: string
old_value: any
new_value: any
action: string
status: 'success' | 'failed' | 'skipped'
handler_output?: string
duration_ms?: number
created_at: number
}
// ============================================
// API Key Types
// ============================================
export interface ApiKey {
id: number
name: string
role: 'admin' | 'ingest' | 'readonly'
allowed_events: string[]
rate_limit: number
last_used_at?: number
created_at: number
}
export interface CreateApiKeyRequest {
name: string
role?: 'admin' | 'ingest' | 'readonly'
allowed_events?: string[]
rate_limit?: number
}
export interface CreateApiKeyResponse extends ApiKey {
key: string
}
// ============================================
// API Types
// ============================================
export interface CreateObjectDefRequest {
name: string
}
export interface CreateObjectRequest {
type: string // name(不需要解析 hash)
}
export interface CreateEventDefRequest {
name: string
schema: {
properties: Record<string, PropertyDef>
}
}
export interface CreateEventRequest {
type: string // name (will be resolved to hash)
payload: Record<string, any>
}
export interface CreateEventResponse {
event: Event
reactions_fired?: number
reaction_results?: ReactionPayload[]
}
export interface CreateProjectionDefRequest {
name: string
sources: Array<{ event_def: string; bindings: Record<string, string>; expression: string }>
params: Record<string, { type: 'ref'; object_type?: string }>
value_schema: { type: string }
initial_value: any // NOT NULL
}
export interface GetProjectionRequest {
name: string // will be resolved to hash
params: Record<string, any>
}
export interface CreateReactionRequest {
projection_def: string // name (will be resolved to hash)
params: Record<string, any>
action?: 'webhook' | 'emit_event' | 'handler' // default: 'webhook'
webhook_url?: string
emit_event_type?: string
emit_payload_template?: string // JSONata expression
handler_code?: string
handler_timeout_ms?: number // default 5000
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["@cloudflare/workers-types/2023-07-01"]
},
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}

View File

@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
plugins: [
{
name: 'html-raw',
enforce: 'pre',
load(id: string) {
if (id.endsWith('.html')) {
return `export default ""`
}
},
},
],
})

View File

@ -0,0 +1,15 @@
name = "ograph"
main = "src/index.ts"
compatibility_date = "2026-04-03"
rules = [
{ type = "Text", globs = ["**/*.html"] }
]
[vars]
VERSION = "2.4.0"
[[d1_databases]]
binding = "DB"
database_name = "ograph"
database_id = "69bd3763-e049-4460-bbde-1eb5954b9dbf"

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"lib": ["ES2022"]
},
"exclude": ["node_modules", "dist", ".wrangler"]
}