import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import crypto from "node:crypto"; import type { AppViewState, DomainEventEnvelope, EventStorePort, NewDomainEvent, ProjectionStorePort } from "@t3tools/core"; interface EventRow { event_id: string; stream_id: string; sequence: number; position: number; type: string; occurred_at: string; payload_json: string; causation_id: string | null; correlation_id: string | null; } function ensureDir(filePath: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } function parseState(raw: string | null): AppViewState | null { if (!raw) return null; return JSON.parse(raw) as AppViewState; } export class SqliteEventStore implements EventStorePort { constructor(private readonly db: DatabaseSync) {} async append(events: ReadonlyArray): Promise> { if (events.length === 0) return []; const out: DomainEventEnvelope[] = []; this.db.exec("BEGIN IMMEDIATE TRANSACTION"); try { for (const event of events) { const sequenceStmt = this.db.prepare( "SELECT COALESCE(MAX(sequence), 0) + 1 AS nextSequence FROM events WHERE stream_id = ?", ); const sequenceRow = sequenceStmt.get(event.streamId) as { nextSequence: number }; const insert = this.db.prepare( [ "INSERT INTO events(", "event_id, stream_id, sequence, type, occurred_at, payload_json, causation_id, correlation_id", ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ].join(" "), ); const eventId = crypto.randomUUID(); insert.run( eventId, event.streamId, sequenceRow.nextSequence, event.type, event.occurredAt, JSON.stringify(event.payload), event.causationId ?? null, event.correlationId ?? null, ); const positionRow = this.db .prepare("SELECT position FROM events WHERE event_id = ?") .get(eventId) as { position: number }; out.push({ eventId, streamId: event.streamId, sequence: sequenceRow.nextSequence, position: positionRow.position, type: event.type, occurredAt: event.occurredAt, payload: event.payload, ...(event.causationId ? { causationId: event.causationId } : {}), ...(event.correlationId ? { correlationId: event.correlationId } : {}), }); } this.db.exec("COMMIT"); return out; } catch (error) { this.db.exec("ROLLBACK"); throw error; } } async loadAll(): Promise> { const stmt = this.db.prepare( "SELECT event_id, stream_id, sequence, position, type, occurred_at, payload_json, causation_id, correlation_id FROM events ORDER BY position ASC", ); const rows = stmt.all() as unknown as EventRow[]; return rows.map(mapEventRow); } async loadAfterPosition(position: number): Promise> { const stmt = this.db.prepare( "SELECT event_id, stream_id, sequence, position, type, occurred_at, payload_json, causation_id, correlation_id FROM events WHERE position > ? ORDER BY position ASC", ); const rows = stmt.all(position) as unknown as EventRow[]; return rows.map(mapEventRow); } } function mapEventRow(row: EventRow): DomainEventEnvelope { return { eventId: row.event_id, streamId: row.stream_id, sequence: row.sequence, position: row.position, type: row.type as DomainEventEnvelope["type"], occurredAt: row.occurred_at, payload: JSON.parse(row.payload_json) as DomainEventEnvelope["payload"], ...(row.causation_id ? { causationId: row.causation_id } : {}), ...(row.correlation_id ? { correlationId: row.correlation_id } : {}), }; } export class SqliteProjectionStore implements ProjectionStorePort { constructor(private readonly db: DatabaseSync) {} async readState(): Promise { const row = this.db .prepare("SELECT state_json FROM projection_state WHERE projection_name = 'app_state'") .get() as { state_json: string } | undefined; return parseState(row?.state_json ?? null); } async writeState(state: AppViewState): Promise { this.db .prepare( [ "INSERT INTO projection_state(projection_name, state_json, updated_at)", "VALUES('app_state', ?, ?)", "ON CONFLICT(projection_name) DO UPDATE SET", "state_json = excluded.state_json,", "updated_at = excluded.updated_at", ].join(" "), ) .run(JSON.stringify(state), new Date().toISOString()); } } export function createSqliteStores(dbPath: string): { db: DatabaseSync; eventStore: SqliteEventStore; projectionStore: SqliteProjectionStore; } { ensureDir(dbPath); const db = new DatabaseSync(dbPath); db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); db.exec( [ "CREATE TABLE IF NOT EXISTS events(", "position INTEGER PRIMARY KEY AUTOINCREMENT,", "event_id TEXT NOT NULL UNIQUE,", "stream_id TEXT NOT NULL,", "sequence INTEGER NOT NULL,", "type TEXT NOT NULL,", "occurred_at TEXT NOT NULL,", "payload_json TEXT NOT NULL,", "causation_id TEXT,", "correlation_id TEXT,", "UNIQUE(stream_id, sequence)", ");", "CREATE INDEX IF NOT EXISTS idx_events_stream_position ON events(stream_id, position);", "CREATE TABLE IF NOT EXISTS projection_state(", "projection_name TEXT PRIMARY KEY,", "state_json TEXT NOT NULL,", "updated_at TEXT NOT NULL", ");", ].join(" "), ); return { db, eventStore: new SqliteEventStore(db), projectionStore: new SqliteProjectionStore(db), }; }