Initial commit: aamos-ledger source + CLAUDE.md context
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user