Files
boc/aamos-ledger/hermes.mjs
T

142 lines
5.6 KiB
JavaScript

/**
* hermes.mjs — AAMOS Hermes Event Fabric adapter
* ─────────────────────────────────────────────────────────────────────────────
* Thin adapter between domain modules and Hermes.
* Publishes events via Redis pub/sub (primary) with JSONL file fallback.
*
* Event envelope:
* {
* id: string — UUIDv4
* trace_id: string — propagated across the full operation chain
* correlation_id:string — groups related events (e.g. one invoice = many events)
* event_type: string — e.g. 'finance.journal.posted'
* source: string — service name, e.g. 'aamos-ledger'
* tenant_id: string — org/tenant isolation
* user_id: string — actor
* entity_type: string — e.g. 'journal_entry'
* entity_id: string — UUID of the affected entity
* decision_source: string — 'system' | 'user' | 'agent' (audit principle)
* payload: object — event-specific data
* ts: string — ISO-8601 UTC
* schema_version: string — '1.0'
* }
*
* Usage:
* import { hermes } from './hermes.mjs';
* await hermes.emit('finance.journal.posted', ctx, { entry_id, amount });
* ─────────────────────────────────────────────────────────────────────────────
*/
import { createClient } from 'redis';
import { appendFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
const HERMES_CHANNEL = process.env.HERMES_CHANNEL || 'aamos:hermes';
// Cloud-native: default till skrivbar /tmp, ej host-path. JSONL-fallback är
// best-effort — får ALDRIG krascha tjänsten (Kafka/Redis är primär transport).
const LOG_DIR = process.env.HERMES_LOG_DIR || '/tmp/hermes';
const LOG_FILE = join(LOG_DIR, 'events.jsonl');
const SERVICE = process.env.SERVICE_NAME || 'aamos-unknown';
let jsonlEnabled = true;
try {
mkdirSync(LOG_DIR, { recursive: true });
} catch (e) {
jsonlEnabled = false;
console.warn(`[hermes] JSONL-fallback inaktiverad (kan ej skapa ${LOG_DIR}): ${e.message}`);
}
// ── Redis client (lazy, non-blocking) ────────────────────────────────────────
let redis = null;
let redisReady = false;
async function getRedis() {
if (redisReady) return redis;
try {
redis = createClient({ url: REDIS_URL });
redis.on('error', (e) => {
redisReady = false;
console.warn(`[hermes] Redis error (fallback to JSONL): ${e.message}`);
});
redis.on('ready', () => { redisReady = true; });
await redis.connect();
redisReady = true;
} catch (e) {
console.warn(`[hermes] Redis unavailable (fallback to JSONL): ${e.message}`);
redis = null;
}
return redis;
}
// ── Event factory ─────────────────────────────────────────────────────────────
function makeEvent(event_type, ctx = {}, payload = {}) {
return {
id: randomUUID(),
trace_id: ctx.trace_id || randomUUID(),
correlation_id: ctx.correlation_id || randomUUID(),
event_type,
source: SERVICE,
tenant_id: ctx.tenant_id || 'wavult-group',
user_id: ctx.user_id || 'system',
entity_type: ctx.entity_type || null,
entity_id: ctx.entity_id || null,
decision_source: ctx.decision_source || 'system',
payload,
ts: new Date().toISOString(),
schema_version: '1.0',
};
}
// ── Publish ───────────────────────────────────────────────────────────────────
async function emit(event_type, ctx = {}, payload = {}) {
const event = makeEvent(event_type, ctx, payload);
const raw = JSON.stringify(event);
// JSONL best-effort (durable fallback + queryable log) — aldrig blockerande
if (jsonlEnabled) {
try {
appendFileSync(LOG_FILE, raw + '\n');
} catch (e) {
console.error('[hermes] JSONL write failed:', e.message);
}
}
// Publish to Redis pub/sub
const client = await getRedis();
if (client && redisReady) {
try {
await client.publish(HERMES_CHANNEL, raw);
} catch (e) {
console.warn('[hermes] Redis publish failed (JSONL still written):', e.message);
}
}
return event;
}
// ── Subscribe (for services that consume events) ──────────────────────────────
async function subscribe(handler) {
const client = await getRedis();
if (!client) {
console.warn('[hermes] subscribe: Redis unavailable, no live events');
return null;
}
const sub = client.duplicate();
await sub.connect();
await sub.subscribe(HERMES_CHANNEL, (raw) => {
try {
const event = JSON.parse(raw);
handler(event);
} catch (e) {
console.warn('[hermes] subscribe parse error:', e.message);
}
});
console.log(`[hermes] subscribed to ${HERMES_CHANNEL}`);
return sub;
}
export const hermes = { emit, subscribe, makeEvent };
export default hermes;