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:
commit
d84a860d15
18
.github/workflows/ci.yml
vendored
Normal file
18
.github/workflows/ci.yml
vendored
Normal 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
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
*.log
|
||||
51
README.md
Normal file
51
README.md
Normal 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 | [](https://www.npmjs.com/package/@uncaged/ograph) |
|
||||
| [`@uncaged/ograph-cli`](packages/cli/) | CLI for managing OGraph instances | [](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
5867
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal 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
46
packages/cli/package.json
Normal 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
254
packages/cli/src/client.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
103
packages/cli/src/commands/config.ts
Normal file
103
packages/cli/src/commands/config.ts
Normal 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
|
||||
}
|
||||
80
packages/cli/src/commands/event-defs.ts
Normal file
80
packages/cli/src/commands/event-defs.ts
Normal 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
|
||||
}
|
||||
109
packages/cli/src/commands/events.ts
Normal file
109
packages/cli/src/commands/events.ts
Normal 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
|
||||
}
|
||||
40
packages/cli/src/commands/health.ts
Normal file
40
packages/cli/src/commands/health.ts
Normal 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
|
||||
}
|
||||
70
packages/cli/src/commands/object-defs.ts
Normal file
70
packages/cli/src/commands/object-defs.ts
Normal 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
|
||||
}
|
||||
97
packages/cli/src/commands/objects.ts
Normal file
97
packages/cli/src/commands/objects.ts
Normal 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
|
||||
}
|
||||
144
packages/cli/src/commands/projection-defs.ts
Normal file
144
packages/cli/src/commands/projection-defs.ts
Normal 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
|
||||
}
|
||||
63
packages/cli/src/commands/projections.ts
Normal file
63
packages/cli/src/commands/projections.ts
Normal 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
|
||||
}
|
||||
145
packages/cli/src/commands/reactions.ts
Normal file
145
packages/cli/src/commands/reactions.ts
Normal 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
|
||||
}
|
||||
65
packages/cli/src/config.ts
Normal file
65
packages/cli/src/config.ts
Normal 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
31
packages/cli/src/index.ts
Normal 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()
|
||||
374
packages/cli/test/client.test.ts
Normal file
374
packages/cli/test/client.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
72
packages/cli/test/config.test.ts
Normal file
72
packages/cli/test/config.test.ts
Normal 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({})
|
||||
})
|
||||
})
|
||||
26
packages/cli/tsconfig.json
Normal file
26
packages/cli/tsconfig.json
Normal 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"]
|
||||
}
|
||||
44
packages/engine/migrations/0006_v2.sql
Normal file
44
packages/engine/migrations/0006_v2.sql
Normal 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);
|
||||
30
packages/engine/migrations/0007_reducers.sql
Normal file
30
packages/engine/migrations/0007_reducers.sql
Normal 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);
|
||||
15
packages/engine/migrations/0008_simplify_reducers.sql
Normal file
15
packages/engine/migrations/0008_simplify_reducers.sql
Normal 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;
|
||||
10
packages/engine/migrations/0009_reactions.sql
Normal file
10
packages/engine/migrations/0009_reactions.sql
Normal 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);
|
||||
65
packages/engine/migrations/0010_v2.1.sql
Normal file
65
packages/engine/migrations/0010_v2.1.sql
Normal 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);
|
||||
82
packages/engine/migrations/0011_v2.2.sql
Normal file
82
packages/engine/migrations/0011_v2.2.sql
Normal 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);
|
||||
104
packages/engine/migrations/0012_v2.3.sql
Normal file
104
packages/engine/migrations/0012_v2.3.sql
Normal 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);
|
||||
113
packages/engine/migrations/0013_v2.4.sql
Normal file
113
packages/engine/migrations/0013_v2.4.sql
Normal 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);
|
||||
46
packages/engine/migrations/0014_bindings.sql
Normal file
46
packages/engine/migrations/0014_bindings.sql
Normal 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);
|
||||
54
packages/engine/migrations/0015_sources.sql
Normal file
54
packages/engine/migrations/0015_sources.sql
Normal 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);
|
||||
17
packages/engine/migrations/0016_reaction_actions.sql
Normal file
17
packages/engine/migrations/0016_reaction_actions.sql
Normal 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);
|
||||
52
packages/engine/migrations/0017_integer_ids.sql
Normal file
52
packages/engine/migrations/0017_integer_ids.sql
Normal 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);
|
||||
15
packages/engine/migrations/0018_reaction_logs.sql
Normal file
15
packages/engine/migrations/0018_reaction_logs.sql
Normal 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);
|
||||
10
packages/engine/migrations/0019_api_keys.sql
Normal file
10
packages/engine/migrations/0019_api_keys.sql
Normal 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)
|
||||
);
|
||||
42
packages/engine/migrations/0020_handler.sql
Normal file
42
packages/engine/migrations/0020_handler.sql
Normal 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)
|
||||
);
|
||||
13
packages/engine/migrations/0021_request_logs.sql
Normal file
13
packages/engine/migrations/0021_request_logs.sql
Normal 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);
|
||||
22
packages/engine/package.json
Normal file
22
packages/engine/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
21
packages/engine/src/auth.ts
Normal file
21
packages/engine/src/auth.ts
Normal 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()
|
||||
})
|
||||
1321
packages/engine/src/engine.ts
Normal file
1321
packages/engine/src/engine.ts
Normal file
File diff suppressed because it is too large
Load Diff
4
packages/engine/src/html.d.ts
vendored
Normal file
4
packages/engine/src/html.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module '*.html' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
2040
packages/engine/src/index.test.ts
Normal file
2040
packages/engine/src/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
483
packages/engine/src/index.ts
Normal file
483
packages/engine/src/index.ts
Normal 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
|
||||
25
packages/engine/src/reaction-executor.ts
Normal file
25
packages/engine/src/reaction-executor.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
240
packages/engine/src/types.ts
Normal file
240
packages/engine/src/types.ts
Normal 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
|
||||
}
|
||||
73
packages/engine/src/ui.html
Normal file
73
packages/engine/src/ui.html
Normal file
File diff suppressed because one or more lines are too long
10
packages/engine/tsconfig.json
Normal file
10
packages/engine/tsconfig.json
Normal 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"]
|
||||
}
|
||||
20
packages/engine/vitest.config.ts
Normal file
20
packages/engine/vitest.config.ts
Normal 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 ""`
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
15
packages/engine/wrangler.toml
Normal file
15
packages/engine/wrangler.toml
Normal 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
20
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user