175 lines · 5.8 KB · TypeScript
RawDownload
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<NewDomainEvent>): Promise<ReadonlyArray<DomainEventEnvelope>> {    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<ReadonlyArray<DomainEventEnvelope>> {    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<ReadonlyArray<DomainEventEnvelope>> {    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<AppViewState | null> {    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<void> {    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),  };}