| author | |
| committer | |
| log | bbf4e6bd3cf4dc1f73f3a0735092cf2f5e88c347 |
| tree | d75e2e4cf6eb23d803b13165cd73ff19cff0bf5d |
| parent | fa1663c1314760505d59cb043562345816f5eb1c |
13 files changed, 403 insertions(+), 337 deletions(-)
apps/server/src/orchestration/engine.test.ts+1-1| ... | ... | @@ -6,8 +6,8 @@ import type { OrchestrationEvent } from "@t3tools/contracts"; |
| 6 | 6 | import { Effect } from "effect"; |
| 7 | 7 | import { afterEach, describe, expect, it } from "vitest"; |
| 8 | 8 | |
| 9 | import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; | |
| 9 | 10 | import { OrchestrationEngine } from "./engine"; |
| 10 | import type { OrchestrationEventRepositoryShape } from "./eventRepository"; | |
| 11 | 11 | |
| 12 | 12 | const tempDirs: string[] = []; |
| 13 | 13 |
apps/server/src/orchestration/engine.ts+2-2| ... | ... | @@ -20,8 +20,8 @@ import { |
| 20 | 20 | } from "effect"; |
| 21 | 21 | |
| 22 | 22 | import { createLogger } from "../logger"; |
| 23 | import type { OrchestrationEventRepositoryShape } from "./eventRepository"; | |
| 24 | import { makeSqliteOrchestrationEventRepository } from "./eventStore"; | |
| 23 | import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; | |
| 24 | import { makeSqliteOrchestrationEventRepository } from "../persistence/Layers/OrchestrationEvents"; | |
| 25 | 25 | import { createEmptyReadModel, reduceEvent } from "./reducer"; |
| 26 | 26 | import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; |
| 27 | 27 |
apps/server/src/orchestration/eventRepository.ts deleted-20| ... | ... | @@ -1,20 +0,0 @@ |
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 2 | import { Context } from "effect"; | |
| 3 | import type { Effect } from "effect"; | |
| 4 | ||
| 5 | export interface OrchestrationEventRepositoryShape { | |
| 6 | readonly append: ( | |
| 7 | event: Omit<OrchestrationEvent, "sequence">, | |
| 8 | ) => Effect.Effect<OrchestrationEvent>; | |
| 9 | readonly readFromSequence: ( | |
| 10 | sequenceExclusive: number, | |
| 11 | limit?: number, | |
| 12 | ) => Effect.Effect<OrchestrationEvent[]>; | |
| 13 | readonly readAll: () => Effect.Effect<OrchestrationEvent[]>; | |
| 14 | readonly close: () => void; | |
| 15 | } | |
| 16 | ||
| 17 | export class OrchestrationEventRepository extends Context.Tag("orchestration/EventRepository")< | |
| 18 | OrchestrationEventRepository, | |
| 19 | OrchestrationEventRepositoryShape | |
| 20 | >() {} |
apps/server/src/orchestration/eventStore.test.ts deleted-90| ... | ... | @@ -1,90 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { DatabaseSync } from "node:sqlite"; | |
| 5 | ||
| 6 | import { Runtime } from "effect"; | |
| 7 | import { afterEach, describe, expect, it } from "vitest"; | |
| 8 | ||
| 9 | import { makeSqliteOrchestrationEventRepository } from "./eventStore"; | |
| 10 | ||
| 11 | const tempDirs: string[] = []; | |
| 12 | ||
| 13 | function makeTempDir(prefix: string): string { | |
| 14 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 15 | tempDirs.push(dir); | |
| 16 | return dir; | |
| 17 | } | |
| 18 | ||
| 19 | afterEach(() => { | |
| 20 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 21 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 22 | } | |
| 23 | }); | |
| 24 | ||
| 25 | describe("SqliteEventStore", () => { | |
| 26 | it("persists and replays events across restarts", async () => { | |
| 27 | const stateDir = makeTempDir("t3code-event-store-"); | |
| 28 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | |
| 29 | const runtime = Runtime.defaultRuntime; | |
| 30 | const createdAt = new Date().toISOString(); | |
| 31 | ||
| 32 | const first = makeSqliteOrchestrationEventRepository(dbPath); | |
| 33 | const saved = await Runtime.runPromise(runtime)( | |
| 34 | first.append({ | |
| 35 | eventId: "event-1", | |
| 36 | type: "project.created", | |
| 37 | aggregateType: "project", | |
| 38 | aggregateId: "project-1", | |
| 39 | occurredAt: createdAt, | |
| 40 | commandId: "cmd-1", | |
| 41 | payload: { id: "project-1", name: "demo" }, | |
| 42 | }), | |
| 43 | ); | |
| 44 | expect(saved.sequence).toBe(1); | |
| 45 | first.close(); | |
| 46 | ||
| 47 | const second = makeSqliteOrchestrationEventRepository(dbPath); | |
| 48 | const replayed = await Runtime.runPromise(runtime)(second.readFromSequence(0)); | |
| 49 | expect(replayed).toEqual([saved]); | |
| 50 | second.close(); | |
| 51 | }); | |
| 52 | ||
| 53 | it("creates and reuses the migrator tracking table", async () => { | |
| 54 | const stateDir = makeTempDir("t3code-event-store-migrations-"); | |
| 55 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | |
| 56 | const runtime = Runtime.defaultRuntime; | |
| 57 | ||
| 58 | const first = makeSqliteOrchestrationEventRepository(dbPath); | |
| 59 | await Runtime.runPromise(runtime)(first.readAll()); | |
| 60 | first.close(); | |
| 61 | ||
| 62 | const second = makeSqliteOrchestrationEventRepository(dbPath); | |
| 63 | await Runtime.runPromise(runtime)(second.readAll()); | |
| 64 | second.close(); | |
| 65 | ||
| 66 | const db = new DatabaseSync(dbPath, { readOnly: true }); | |
| 67 | const tableRow = db | |
| 68 | .prepare( | |
| 69 | ` | |
| 70 | SELECT name | |
| 71 | FROM sqlite_master | |
| 72 | WHERE type = 'table' AND name = 'orchestration_sql_migrations' | |
| 73 | `, | |
| 74 | ) | |
| 75 | .get() as { name: string } | undefined; | |
| 76 | expect(tableRow?.name).toBe("orchestration_sql_migrations"); | |
| 77 | ||
| 78 | const migrationRows = db | |
| 79 | .prepare( | |
| 80 | ` | |
| 81 | SELECT * | |
| 82 | FROM orchestration_sql_migrations | |
| 83 | `, | |
| 84 | ) | |
| 85 | .all() as Array<Record<string, unknown>>; | |
| 86 | expect(migrationRows).toHaveLength(1); | |
| 87 | expect(Object.values(migrationRows[0] ?? {}).length).toBeGreaterThan(0); | |
| 88 | db.close(); | |
| 89 | }); | |
| 90 | }); |
apps/server/src/orchestration/eventStore.ts deleted-190| ... | ... | @@ -1,190 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 5 | import { OrchestrationEventSchema } from "@t3tools/contracts"; | |
| 6 | import type { SqlClient as SqlClientService } from "@effect/sql/SqlClient"; | |
| 7 | import * as SqlClient from "@effect/sql/SqlClient"; | |
| 8 | import * as SqlSchema from "@effect/sql/SqlSchema"; | |
| 9 | import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; | |
| 10 | import { Effect, ManagedRuntime, Schema } from "effect"; | |
| 11 | ||
| 12 | import type { OrchestrationEventRepositoryShape } from "./eventRepository"; | |
| 13 | import { runOrchestrationMigrations } from "./migrations"; | |
| 14 | ||
| 15 | const decodeEvent = Schema.decodeUnknownSync(OrchestrationEventSchema); | |
| 16 | ||
| 17 | const EventRowSchema = Schema.Struct({ | |
| 18 | sequence: Schema.Number, | |
| 19 | eventId: Schema.String, | |
| 20 | type: Schema.String, | |
| 21 | aggregateType: Schema.String, | |
| 22 | aggregateId: Schema.String, | |
| 23 | occurredAt: Schema.String, | |
| 24 | commandId: Schema.NullOr(Schema.String), | |
| 25 | payloadJson: Schema.String, | |
| 26 | }); | |
| 27 | ||
| 28 | type EventRow = Schema.Schema.Type<typeof EventRowSchema>; | |
| 29 | ||
| 30 | const AppendEventRequestSchema = Schema.Struct({ | |
| 31 | eventId: Schema.String, | |
| 32 | type: Schema.String, | |
| 33 | aggregateType: Schema.String, | |
| 34 | aggregateId: Schema.String, | |
| 35 | occurredAt: Schema.String, | |
| 36 | commandId: Schema.NullOr(Schema.String), | |
| 37 | payloadJson: Schema.String, | |
| 38 | }); | |
| 39 | ||
| 40 | const ReadFromSequenceRequestSchema = Schema.Struct({ | |
| 41 | sequenceExclusive: Schema.Number, | |
| 42 | limit: Schema.Number, | |
| 43 | }); | |
| 44 | ||
| 45 | const appendEventRow = SqlSchema.single({ | |
| 46 | Request: AppendEventRequestSchema, | |
| 47 | Result: EventRowSchema, | |
| 48 | execute: (request) => | |
| 49 | Effect.flatMap(SqlClient.SqlClient, (sql) => | |
| 50 | sql` | |
| 51 | INSERT INTO orchestration_events ( | |
| 52 | event_id, | |
| 53 | event_type, | |
| 54 | aggregate_type, | |
| 55 | aggregate_id, | |
| 56 | occurred_at, | |
| 57 | command_id, | |
| 58 | payload_json | |
| 59 | ) | |
| 60 | VALUES ( | |
| 61 | ${request.eventId}, | |
| 62 | ${request.type}, | |
| 63 | ${request.aggregateType}, | |
| 64 | ${request.aggregateId}, | |
| 65 | ${request.occurredAt}, | |
| 66 | ${request.commandId}, | |
| 67 | ${request.payloadJson} | |
| 68 | ) | |
| 69 | RETURNING | |
| 70 | sequence, | |
| 71 | event_id AS "eventId", | |
| 72 | event_type AS "type", | |
| 73 | aggregate_type AS "aggregateType", | |
| 74 | aggregate_id AS "aggregateId", | |
| 75 | occurred_at AS "occurredAt", | |
| 76 | command_id AS "commandId", | |
| 77 | payload_json AS "payloadJson" | |
| 78 | `, | |
| 79 | ), | |
| 80 | }); | |
| 81 | ||
| 82 | const readEventRowsFromSequence = SqlSchema.findAll({ | |
| 83 | Request: ReadFromSequenceRequestSchema, | |
| 84 | Result: EventRowSchema, | |
| 85 | execute: (request) => | |
| 86 | Effect.flatMap(SqlClient.SqlClient, (sql) => | |
| 87 | sql` | |
| 88 | SELECT | |
| 89 | sequence, | |
| 90 | event_id AS "eventId", | |
| 91 | event_type AS "type", | |
| 92 | aggregate_type AS "aggregateType", | |
| 93 | aggregate_id AS "aggregateId", | |
| 94 | occurred_at AS "occurredAt", | |
| 95 | command_id AS "commandId", | |
| 96 | payload_json AS "payloadJson" | |
| 97 | FROM orchestration_events | |
| 98 | WHERE sequence > ${request.sequenceExclusive} | |
| 99 | ORDER BY sequence ASC | |
| 100 | LIMIT ${request.limit} | |
| 101 | `, | |
| 102 | ), | |
| 103 | }); | |
| 104 | ||
| 105 | function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { | |
| 106 | return decodeEvent({ | |
| 107 | sequence: row.sequence, | |
| 108 | eventId: row.eventId, | |
| 109 | type: row.type, | |
| 110 | aggregateType: row.aggregateType, | |
| 111 | aggregateId: row.aggregateId, | |
| 112 | occurredAt: row.occurredAt, | |
| 113 | commandId: row.commandId, | |
| 114 | payload: JSON.parse(row.payloadJson) as unknown, | |
| 115 | }); | |
| 116 | } | |
| 117 | ||
| 118 | export function makeSqliteOrchestrationEventRepository( | |
| 119 | dbPath: string, | |
| 120 | ): OrchestrationEventRepositoryShape { | |
| 121 | fs.mkdirSync(path.dirname(dbPath), { recursive: true }); | |
| 122 | ||
| 123 | const runtime: ManagedRuntime.ManagedRuntime<SqlClientService, unknown> = ManagedRuntime.make( | |
| 124 | SqliteClient.layer({ | |
| 125 | filename: dbPath, | |
| 126 | }), | |
| 127 | ); | |
| 128 | let closed = false; | |
| 129 | ||
| 130 | const provideSql = <A, E>( | |
| 131 | effect: Effect.Effect<A, E, SqlClientService>, | |
| 132 | ): Effect.Effect<A, E | unknown> => Effect.provide(effect, runtime); | |
| 133 | ||
| 134 | const initialize = Effect.gen(function* () { | |
| 135 | const sql = yield* SqlClient.SqlClient; | |
| 136 | yield* sql`PRAGMA journal_mode = WAL;`; | |
| 137 | yield* sql`PRAGMA foreign_keys = ON;`; | |
| 138 | yield* runOrchestrationMigrations; | |
| 139 | }); | |
| 140 | ||
| 141 | const initialized = runtime.runPromise(initialize).then(() => undefined); | |
| 142 | const ensureInitialized = Effect.promise(() => initialized); | |
| 143 | ||
| 144 | const append: OrchestrationEventRepositoryShape["append"] = (event) => | |
| 145 | Effect.gen(function* () { | |
| 146 | yield* ensureInitialized; | |
| 147 | const row = yield* provideSql( | |
| 148 | appendEventRow({ | |
| 149 | eventId: event.eventId, | |
| 150 | type: event.type, | |
| 151 | aggregateType: event.aggregateType, | |
| 152 | aggregateId: event.aggregateId, | |
| 153 | occurredAt: event.occurredAt, | |
| 154 | commandId: event.commandId, | |
| 155 | payloadJson: JSON.stringify(event.payload), | |
| 156 | }), | |
| 157 | ); | |
| 158 | return eventRowToOrchestrationEvent(row); | |
| 159 | }).pipe(Effect.orDie); | |
| 160 | ||
| 161 | const readFromSequence: OrchestrationEventRepositoryShape["readFromSequence"] = ( | |
| 162 | sequenceExclusive, | |
| 163 | limit = 1_000, | |
| 164 | ) => | |
| 165 | Effect.gen(function* () { | |
| 166 | yield* ensureInitialized; | |
| 167 | const rows = yield* provideSql( | |
| 168 | readEventRowsFromSequence({ | |
| 169 | sequenceExclusive, | |
| 170 | limit, | |
| 171 | }), | |
| 172 | ); | |
| 173 | return rows.map(eventRowToOrchestrationEvent); | |
| 174 | }).pipe(Effect.orDie); | |
| 175 | ||
| 176 | const close = (): void => { | |
| 177 | if (closed) { | |
| 178 | return; | |
| 179 | } | |
| 180 | closed = true; | |
| 181 | void runtime.dispose(); | |
| 182 | }; | |
| 183 | ||
| 184 | return { | |
| 185 | append, | |
| 186 | readFromSequence, | |
| 187 | readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), | |
| 188 | close, | |
| 189 | }; | |
| 190 | } |
apps/server/src/orchestration/layers.ts+2-2| ... | ... | @@ -2,9 +2,9 @@ import path from "node:path"; |
| 2 | 2 | |
| 3 | 3 | import { Effect, Layer } from "effect"; |
| 4 | 4 | |
| 5 | import { OrchestrationEventRepository } from "./eventRepository"; | |
| 5 | import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; | |
| 6 | import { makeSqliteOrchestrationEventRepository } from "../persistence/Layers/OrchestrationEvents"; | |
| 6 | 7 | import { OrchestrationEngine } from "./engine"; |
| 7 | import { makeSqliteOrchestrationEventRepository } from "./eventStore"; | |
| 8 | 8 | import { OrchestrationConfig, OrchestrationEngineService } from "./services"; |
| 9 | 9 | |
| 10 | 10 | export const OrchestrationEventRepositoryLive = Layer.effect( |
apps/server/src/orchestration/migrations.ts deleted-31| ... | ... | @@ -1,31 +0,0 @@ |
| 1 | import * as Migrator from "@effect/sql/Migrator"; | |
| 2 | import * as SqlClient from "@effect/sql/SqlClient"; | |
| 3 | import { Effect } from "effect"; | |
| 4 | ||
| 5 | const ORCHESTRATION_MIGRATIONS = Migrator.fromRecord({ | |
| 6 | "0001_create_orchestration_events": Effect.flatMap(SqlClient.SqlClient, (sql) => | |
| 7 | Effect.gen(function* () { | |
| 8 | yield* sql` | |
| 9 | CREATE TABLE IF NOT EXISTS orchestration_events ( | |
| 10 | sequence INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 11 | event_id TEXT NOT NULL UNIQUE, | |
| 12 | event_type TEXT NOT NULL, | |
| 13 | aggregate_type TEXT NOT NULL, | |
| 14 | aggregate_id TEXT NOT NULL, | |
| 15 | occurred_at TEXT NOT NULL, | |
| 16 | command_id TEXT, | |
| 17 | payload_json TEXT NOT NULL | |
| 18 | ) | |
| 19 | `; | |
| 20 | yield* sql` | |
| 21 | CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate | |
| 22 | ON orchestration_events(aggregate_type, aggregate_id, sequence) | |
| 23 | `; | |
| 24 | }), | |
| 25 | ), | |
| 26 | }); | |
| 27 | ||
| 28 | export const runOrchestrationMigrations = Migrator.make({})({ | |
| 29 | loader: ORCHESTRATION_MIGRATIONS, | |
| 30 | table: "orchestration_sql_migrations", | |
| 31 | }); |
apps/server/src/persistence/Layers/OrchestrationEvents.test.ts created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { DatabaseSync } from "node:sqlite"; | |
| 5 | ||
| 6 | import { Runtime } from "effect"; | |
| 7 | import { afterEach, describe, expect, it } from "vitest"; | |
| 8 | ||
| 9 | import { makeSqliteOrchestrationEventRepository } from "./OrchestrationEvents"; | |
| 10 | ||
| 11 | const tempDirs: string[] = []; | |
| 12 | ||
| 13 | function makeTempDir(prefix: string): string { | |
| 14 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 15 | tempDirs.push(dir); | |
| 16 | return dir; | |
| 17 | } | |
| 18 | ||
| 19 | afterEach(() => { | |
| 20 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 21 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 22 | } | |
| 23 | }); | |
| 24 | ||
| 25 | describe("SqliteEventStore", () => { | |
| 26 | it("persists and replays events across restarts", async () => { | |
| 27 | const stateDir = makeTempDir("t3code-event-store-"); | |
| 28 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | |
| 29 | const runtime = Runtime.defaultRuntime; | |
| 30 | const createdAt = new Date().toISOString(); | |
| 31 | ||
| 32 | const first = makeSqliteOrchestrationEventRepository(dbPath); | |
| 33 | const saved = await Runtime.runPromise(runtime)( | |
| 34 | first.append({ | |
| 35 | eventId: "event-1", | |
| 36 | type: "project.created", | |
| 37 | aggregateType: "project", | |
| 38 | aggregateId: "project-1", | |
| 39 | occurredAt: createdAt, | |
| 40 | commandId: "cmd-1", | |
| 41 | payload: { id: "project-1", name: "demo" }, | |
| 42 | }), | |
| 43 | ); | |
| 44 | expect(saved.sequence).toBe(1); | |
| 45 | first.close(); | |
| 46 | ||
| 47 | const second = makeSqliteOrchestrationEventRepository(dbPath); | |
| 48 | const replayed = await Runtime.runPromise(runtime)(second.readFromSequence(0)); | |
| 49 | expect(replayed).toEqual([saved]); | |
| 50 | second.close(); | |
| 51 | }); | |
| 52 | ||
| 53 | it("creates and reuses the migrator tracking table", async () => { | |
| 54 | const stateDir = makeTempDir("t3code-event-store-migrations-"); | |
| 55 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | |
| 56 | const runtime = Runtime.defaultRuntime; | |
| 57 | ||
| 58 | const first = makeSqliteOrchestrationEventRepository(dbPath); | |
| 59 | await Runtime.runPromise(runtime)(first.readAll()); | |
| 60 | first.close(); | |
| 61 | ||
| 62 | const second = makeSqliteOrchestrationEventRepository(dbPath); | |
| 63 | await Runtime.runPromise(runtime)(second.readAll()); | |
| 64 | second.close(); | |
| 65 | ||
| 66 | const db = new DatabaseSync(dbPath, { readOnly: true }); | |
| 67 | const tableRow = db | |
| 68 | .prepare( | |
| 69 | ` | |
| 70 | SELECT name | |
| 71 | FROM sqlite_master | |
| 72 | WHERE type = 'table' AND name IN ('effect_sql_migrations', 'orchestration_sql_migrations') | |
| 73 | ORDER BY name ASC | |
| 74 | `, | |
| 75 | ) | |
| 76 | .get() as { name: string } | undefined; | |
| 77 | expect(tableRow?.name).toBeDefined(); | |
| 78 | if (!tableRow) { | |
| 79 | throw new Error("Expected migrations table to exist"); | |
| 80 | } | |
| 81 | ||
| 82 | const migrationRows = db | |
| 83 | .prepare( | |
| 84 | ` | |
| 85 | SELECT * | |
| 86 | FROM ${tableRow.name} | |
| 87 | `, | |
| 88 | ) | |
| 89 | .all() as Array<Record<string, unknown>>; | |
| 90 | expect(migrationRows).toHaveLength(1); | |
| 91 | expect(Object.values(migrationRows[0] ?? {}).length).toBeGreaterThan(0); | |
| 92 | db.close(); | |
| 93 | }); | |
| 94 | }); |
apps/server/src/persistence/Layers/OrchestrationEvents.ts created+190| ... | ... | @@ -0,0 +1,190 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 5 | import { OrchestrationEventSchema } from "@t3tools/contracts"; | |
| 6 | import type { SqlClient as SqlClientService } from "@effect/sql/SqlClient"; | |
| 7 | import * as SqlClient from "@effect/sql/SqlClient"; | |
| 8 | import * as SqlSchema from "@effect/sql/SqlSchema"; | |
| 9 | import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; | |
| 10 | import { Effect, ManagedRuntime, Schema } from "effect"; | |
| 11 | ||
| 12 | import type { OrchestrationEventRepositoryShape } from "../Services/OrchestrationEvents"; | |
| 13 | import { runMigrations } from "../Migrations"; | |
| 14 | ||
| 15 | const decodeEvent = Schema.decodeUnknownSync(OrchestrationEventSchema); | |
| 16 | ||
| 17 | const EventRowSchema = Schema.Struct({ | |
| 18 | sequence: Schema.Number, | |
| 19 | eventId: Schema.String, | |
| 20 | type: Schema.String, | |
| 21 | aggregateType: Schema.String, | |
| 22 | aggregateId: Schema.String, | |
| 23 | occurredAt: Schema.String, | |
| 24 | commandId: Schema.NullOr(Schema.String), | |
| 25 | payloadJson: Schema.String, | |
| 26 | }); | |
| 27 | ||
| 28 | type EventRow = Schema.Schema.Type<typeof EventRowSchema>; | |
| 29 | ||
| 30 | const AppendEventRequestSchema = Schema.Struct({ | |
| 31 | eventId: Schema.String, | |
| 32 | type: Schema.String, | |
| 33 | aggregateType: Schema.String, | |
| 34 | aggregateId: Schema.String, | |
| 35 | occurredAt: Schema.String, | |
| 36 | commandId: Schema.NullOr(Schema.String), | |
| 37 | payloadJson: Schema.String, | |
| 38 | }); | |
| 39 | ||
| 40 | const ReadFromSequenceRequestSchema = Schema.Struct({ | |
| 41 | sequenceExclusive: Schema.Number, | |
| 42 | limit: Schema.Number, | |
| 43 | }); | |
| 44 | ||
| 45 | const appendEventRow = SqlSchema.single({ | |
| 46 | Request: AppendEventRequestSchema, | |
| 47 | Result: EventRowSchema, | |
| 48 | execute: (request) => | |
| 49 | Effect.flatMap(SqlClient.SqlClient, (sql) => | |
| 50 | sql` | |
| 51 | INSERT INTO orchestration_events ( | |
| 52 | event_id, | |
| 53 | event_type, | |
| 54 | aggregate_type, | |
| 55 | aggregate_id, | |
| 56 | occurred_at, | |
| 57 | command_id, | |
| 58 | payload_json | |
| 59 | ) | |
| 60 | VALUES ( | |
| 61 | ${request.eventId}, | |
| 62 | ${request.type}, | |
| 63 | ${request.aggregateType}, | |
| 64 | ${request.aggregateId}, | |
| 65 | ${request.occurredAt}, | |
| 66 | ${request.commandId}, | |
| 67 | ${request.payloadJson} | |
| 68 | ) | |
| 69 | RETURNING | |
| 70 | sequence, | |
| 71 | event_id AS "eventId", | |
| 72 | event_type AS "type", | |
| 73 | aggregate_type AS "aggregateType", | |
| 74 | aggregate_id AS "aggregateId", | |
| 75 | occurred_at AS "occurredAt", | |
| 76 | command_id AS "commandId", | |
| 77 | payload_json AS "payloadJson" | |
| 78 | `, | |
| 79 | ), | |
| 80 | }); | |
| 81 | ||
| 82 | const readEventRowsFromSequence = SqlSchema.findAll({ | |
| 83 | Request: ReadFromSequenceRequestSchema, | |
| 84 | Result: EventRowSchema, | |
| 85 | execute: (request) => | |
| 86 | Effect.flatMap(SqlClient.SqlClient, (sql) => | |
| 87 | sql` | |
| 88 | SELECT | |
| 89 | sequence, | |
| 90 | event_id AS "eventId", | |
| 91 | event_type AS "type", | |
| 92 | aggregate_type AS "aggregateType", | |
| 93 | aggregate_id AS "aggregateId", | |
| 94 | occurred_at AS "occurredAt", | |
| 95 | command_id AS "commandId", | |
| 96 | payload_json AS "payloadJson" | |
| 97 | FROM orchestration_events | |
| 98 | WHERE sequence > ${request.sequenceExclusive} | |
| 99 | ORDER BY sequence ASC | |
| 100 | LIMIT ${request.limit} | |
| 101 | `, | |
| 102 | ), | |
| 103 | }); | |
| 104 | ||
| 105 | function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { | |
| 106 | return decodeEvent({ | |
| 107 | sequence: row.sequence, | |
| 108 | eventId: row.eventId, | |
| 109 | type: row.type, | |
| 110 | aggregateType: row.aggregateType, | |
| 111 | aggregateId: row.aggregateId, | |
| 112 | occurredAt: row.occurredAt, | |
| 113 | commandId: row.commandId, | |
| 114 | payload: JSON.parse(row.payloadJson) as unknown, | |
| 115 | }); | |
| 116 | } | |
| 117 | ||
| 118 | export function makeSqliteOrchestrationEventRepository( | |
| 119 | dbPath: string, | |
| 120 | ): OrchestrationEventRepositoryShape { | |
| 121 | fs.mkdirSync(path.dirname(dbPath), { recursive: true }); | |
| 122 | ||
| 123 | const runtime: ManagedRuntime.ManagedRuntime<SqlClientService, unknown> = ManagedRuntime.make( | |
| 124 | SqliteClient.layer({ | |
| 125 | filename: dbPath, | |
| 126 | }), | |
| 127 | ); | |
| 128 | let closed = false; | |
| 129 | ||
| 130 | const provideSql = <A, E>( | |
| 131 | effect: Effect.Effect<A, E, SqlClientService>, | |
| 132 | ): Effect.Effect<A, E | unknown> => Effect.provide(effect, runtime); | |
| 133 | ||
| 134 | const initialize = Effect.gen(function* () { | |
| 135 | const sql = yield* SqlClient.SqlClient; | |
| 136 | yield* sql`PRAGMA journal_mode = WAL;`; | |
| 137 | yield* sql`PRAGMA foreign_keys = ON;`; | |
| 138 | yield* runMigrations; | |
| 139 | }); | |
| 140 | ||
| 141 | const initialized = runtime.runPromise(initialize).then(() => undefined); | |
| 142 | const ensureInitialized = Effect.promise(() => initialized); | |
| 143 | ||
| 144 | const append: OrchestrationEventRepositoryShape["append"] = (event) => | |
| 145 | Effect.gen(function* () { | |
| 146 | yield* ensureInitialized; | |
| 147 | const row = yield* provideSql( | |
| 148 | appendEventRow({ | |
| 149 | eventId: event.eventId, | |
| 150 | type: event.type, | |
| 151 | aggregateType: event.aggregateType, | |
| 152 | aggregateId: event.aggregateId, | |
| 153 | occurredAt: event.occurredAt, | |
| 154 | commandId: event.commandId, | |
| 155 | payloadJson: JSON.stringify(event.payload), | |
| 156 | }), | |
| 157 | ); | |
| 158 | return eventRowToOrchestrationEvent(row); | |
| 159 | }).pipe(Effect.orDie); | |
| 160 | ||
| 161 | const readFromSequence: OrchestrationEventRepositoryShape["readFromSequence"] = ( | |
| 162 | sequenceExclusive, | |
| 163 | limit = 1_000, | |
| 164 | ) => | |
| 165 | Effect.gen(function* () { | |
| 166 | yield* ensureInitialized; | |
| 167 | const rows = yield* provideSql( | |
| 168 | readEventRowsFromSequence({ | |
| 169 | sequenceExclusive, | |
| 170 | limit, | |
| 171 | }), | |
| 172 | ); | |
| 173 | return rows.map(eventRowToOrchestrationEvent); | |
| 174 | }).pipe(Effect.orDie); | |
| 175 | ||
| 176 | const close = (): void => { | |
| 177 | if (closed) { | |
| 178 | return; | |
| 179 | } | |
| 180 | closed = true; | |
| 181 | void runtime.dispose(); | |
| 182 | }; | |
| 183 | ||
| 184 | return { | |
| 185 | append, | |
| 186 | readFromSequence, | |
| 187 | readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), | |
| 188 | close, | |
| 189 | }; | |
| 190 | } |
apps/server/src/persistence/Migrations.ts created+67| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | /** | |
| 2 | * MigrationsLive - Migration runner with inline loader | |
| 3 | * | |
| 4 | * Uses Migrator.make with fromRecord to define migrations inline. | |
| 5 | * All migrations are statically imported - no dynamic file system loading. | |
| 6 | * | |
| 7 | * Migrations run automatically when the MigrationLayer is provided, | |
| 8 | * ensuring the database schema is always up-to-date before the application starts. | |
| 9 | */ | |
| 10 | ||
| 11 | import * as Migrator from "@effect/sql/Migrator"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | ||
| 14 | // Import all migrations statically | |
| 15 | import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; | |
| 16 | ||
| 17 | ||
| 18 | /** | |
| 19 | * Migration loader with all migrations defined inline. | |
| 20 | * | |
| 21 | * Key format: "{id}_{name}" where: | |
| 22 | * - id: numeric migration ID (determines execution order) | |
| 23 | * - name: descriptive name for the migration | |
| 24 | * | |
| 25 | * Uses Migrator.fromRecord which parses the key format and | |
| 26 | * returns migrations sorted by ID. | |
| 27 | */ | |
| 28 | const loader = Migrator.fromRecord({ | |
| 29 | "1_OrchestrationEvents": Migration0001, | |
| 30 | }); | |
| 31 | ||
| 32 | /** | |
| 33 | * Migrator run function - no schema dumping needed | |
| 34 | * Uses the base Migrator.make without platform dependencies | |
| 35 | */ | |
| 36 | const run = Migrator.make({}); | |
| 37 | ||
| 38 | /** | |
| 39 | * Run all pending migrations. | |
| 40 | * | |
| 41 | * Creates the migrations tracking table (effect_sql_migrations) if it doesn't exist, | |
| 42 | * then runs any migrations with ID greater than the latest recorded migration. | |
| 43 | * | |
| 44 | * Returns array of [id, name] tuples for migrations that were run. | |
| 45 | * | |
| 46 | * @returns Effect containing array of executed migrations | |
| 47 | */ | |
| 48 | export const runMigrations = run({ loader }); | |
| 49 | ||
| 50 | /** | |
| 51 | * Layer that runs migrations when the layer is built. | |
| 52 | * | |
| 53 | * Use this to ensure migrations run before your application starts. | |
| 54 | * Migrations are run automatically - no separate script is needed. | |
| 55 | * | |
| 56 | * @example | |
| 57 | * ```typescript | |
| 58 | * import { MigrationsLive } from "@acme/db/Migrations" | |
| 59 | * import { PgClient } from "@effect/sql-pg" | |
| 60 | * | |
| 61 | * // Migrations run automatically when PgClient is provided | |
| 62 | * const AppLayer = MigrationsLive.pipe( | |
| 63 | * Layer.provideMerge(PgClient.layer({ url: Redacted.make("postgresql://...") })) | |
| 64 | * ) | |
| 65 | * ``` | |
| 66 | */ | |
| 67 | export const MigrationsLive = Layer.effectDiscard(runMigrations); | |
| \ No newline at end of file |
apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | import { SqlClient } from "@effect/sql"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | yield* sql` | |
| 8 | CREATE TABLE IF NOT EXISTS orchestration_events ( | |
| 9 | sequence INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 10 | event_id TEXT NOT NULL UNIQUE, | |
| 11 | event_type TEXT NOT NULL, | |
| 12 | aggregate_type TEXT NOT NULL, | |
| 13 | aggregate_id TEXT NOT NULL, | |
| 14 | occurred_at TEXT NOT NULL, | |
| 15 | command_id TEXT, | |
| 16 | payload_json TEXT NOT NULL | |
| 17 | ) | |
| 18 | `; | |
| 19 | ||
| 20 | yield* sql` | |
| 21 | CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate | |
| 22 | ON orchestration_events(aggregate_type, aggregate_id, sequence) | |
| 23 | `; | |
| 24 | }); | |
| \ No newline at end of file |
apps/server/src/persistence/Services/OrchestrationEvents.ts created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 2 | import { Context } from "effect"; | |
| 3 | import type { Effect } from "effect"; | |
| 4 | ||
| 5 | export interface OrchestrationEventRepositoryShape { | |
| 6 | readonly append: ( | |
| 7 | event: Omit<OrchestrationEvent, "sequence">, | |
| 8 | ) => Effect.Effect<OrchestrationEvent>; | |
| 9 | readonly readFromSequence: ( | |
| 10 | sequenceExclusive: number, | |
| 11 | limit?: number, | |
| 12 | ) => Effect.Effect<OrchestrationEvent[]>; | |
| 13 | readonly readAll: () => Effect.Effect<OrchestrationEvent[]>; | |
| 14 | readonly close: () => void; | |
| 15 | } | |
| 16 | ||
| 17 | export class OrchestrationEventRepository extends Context.Tag("orchestration/EventRepository")< | |
| 18 | OrchestrationEventRepository, | |
| 19 | OrchestrationEventRepositoryShape | |
| 20 | >() {} |
apps/server/tsconfig.json+3-1| ... | ... | @@ -3,7 +3,9 @@ |
| 3 | 3 | "compilerOptions": { |
| 4 | 4 | "composite": true, |
| 5 | 5 | "types": ["node"], |
| 6 | "lib": ["ES2023", "esnext.disposable"] | |
| 6 | "lib": ["ES2023", "esnext.disposable"], | |
| 7 | "noEmit": true, | |
| 8 | "allowImportingTsExtensions": true | |
| 7 | 9 | }, |
| 8 | 10 | "include": ["src", "tsdown.config.ts"] |
| 9 | 11 | } |