| author | |
| committer | |
| log | fa1663c1314760505d59cb043562345816f5eb1c |
| tree | 606af09d561a3e55f30fc1534ad79e2254a07d80 |
| parent | 9b046feb1edbd95998daaf00dc68ebd229d5bb45 |
8 files changed, 210 insertions(+), 98 deletions(-)
apps/server/src/orchestration/engine.test.ts+2-2| ... | ... | @@ -7,7 +7,7 @@ import { Effect } from "effect"; |
| 7 | 7 | import { afterEach, describe, expect, it } from "vitest"; |
| 8 | 8 | |
| 9 | 9 | import { OrchestrationEngine } from "./engine"; |
| 10 | import type { OrchestrationEventStore } from "./eventStore"; | |
| 10 | import type { OrchestrationEventRepositoryShape } from "./eventRepository"; | |
| 11 | 11 | |
| 12 | 12 | const tempDirs: string[] = []; |
| 13 | 13 | |
| ... | ... | @@ -319,7 +319,7 @@ describe("OrchestrationEngine", () => { |
| 319 | 319 | let nextSequence = 1; |
| 320 | 320 | let shouldFailFirstAppend = true; |
| 321 | 321 | |
| 322 | const flakyStore: OrchestrationEventStore = { | |
| 322 | const flakyStore: OrchestrationEventRepositoryShape = { | |
| 323 | 323 | append(event) { |
| 324 | 324 | if (shouldFailFirstAppend) { |
| 325 | 325 | shouldFailFirstAppend = false; |
apps/server/src/orchestration/engine.ts+6-6| ... | ... | @@ -20,8 +20,8 @@ import { |
| 20 | 20 | } from "effect"; |
| 21 | 21 | |
| 22 | 22 | import { createLogger } from "../logger"; |
| 23 | import type { OrchestrationEventStore } from "./eventStore"; | |
| 24 | import { SqliteEventStore } from "./eventStore"; | |
| 23 | import type { OrchestrationEventRepositoryShape } from "./eventRepository"; | |
| 24 | import { makeSqliteOrchestrationEventRepository } from "./eventStore"; | |
| 25 | 25 | import { createEmptyReadModel, reduceEvent } from "./reducer"; |
| 26 | 26 | import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; |
| 27 | 27 | |
| ... | ... | @@ -205,7 +205,7 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEve |
| 205 | 205 | export class OrchestrationEngine { |
| 206 | 206 | private readonly logger = createLogger("orchestration"); |
| 207 | 207 | private readonly runtime = Runtime.defaultRuntime; |
| 208 | private readonly eventStore: OrchestrationEventStore; | |
| 208 | private readonly eventStore: OrchestrationEventRepositoryShape; | |
| 209 | 209 | |
| 210 | 210 | private readModel: OrchestrationReadModel; |
| 211 | 211 | private commandQueue: Queue.Queue<CommandEnvelope>; |
| ... | ... | @@ -216,11 +216,11 @@ export class OrchestrationEngine { |
| 216 | 216 | private readonly domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); |
| 217 | 217 | |
| 218 | 218 | constructor(stateDir: string); |
| 219 | constructor(eventStore: OrchestrationEventStore); | |
| 220 | constructor(stateDirOrEventStore: string | OrchestrationEventStore) { | |
| 219 | constructor(eventStore: OrchestrationEventRepositoryShape); | |
| 220 | constructor(stateDirOrEventStore: string | OrchestrationEventRepositoryShape) { | |
| 221 | 221 | if (typeof stateDirOrEventStore === "string") { |
| 222 | 222 | const dbPath = path.join(stateDirOrEventStore, "orchestration.sqlite"); |
| 223 | this.eventStore = new SqliteEventStore(dbPath); | |
| 223 | this.eventStore = makeSqliteOrchestrationEventRepository(dbPath); | |
| 224 | 224 | } else { |
| 225 | 225 | this.eventStore = stateDirOrEventStore; |
| 226 | 226 | } |
apps/server/src/orchestration/eventRepository.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/src/orchestration/eventStore.test.ts created+90| ... | ... | @@ -0,0 +1,90 @@ |
| 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+51-74| ... | ... | @@ -4,17 +4,13 @@ import path from "node:path"; |
| 4 | 4 | import type { OrchestrationEvent } from "@t3tools/contracts"; |
| 5 | 5 | import { OrchestrationEventSchema } from "@t3tools/contracts"; |
| 6 | 6 | import type { SqlClient as SqlClientService } from "@effect/sql/SqlClient"; |
| 7 | import * as SqlClient from "@effect/sql/SqlClient"; | |
| 7 | import * as SqlClient from "@effect/sql/SqlClient"; | |
| 8 | 8 | import * as SqlSchema from "@effect/sql/SqlSchema"; |
| 9 | 9 | import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; |
| 10 | 10 | import { Effect, ManagedRuntime, Schema } from "effect"; |
| 11 | 11 | |
| 12 | export interface OrchestrationEventStore { | |
| 13 | append(event: Omit<OrchestrationEvent, "sequence">): Effect.Effect<OrchestrationEvent>; | |
| 14 | readFromSequence(sequenceExclusive: number, limit?: number): Effect.Effect<OrchestrationEvent[]>; | |
| 15 | readAll(): Effect.Effect<OrchestrationEvent[]>; | |
| 16 | close(): void; | |
| 17 | } | |
| 12 | import type { OrchestrationEventRepositoryShape } from "./eventRepository"; | |
| 13 | import { runOrchestrationMigrations } from "./migrations"; | |
| 18 | 14 | |
| 19 | 15 | const decodeEvent = Schema.decodeUnknownSync(OrchestrationEventSchema); |
| 20 | 16 | |
| ... | ... | @@ -119,59 +115,36 @@ function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { |
| 119 | 115 | }); |
| 120 | 116 | } |
| 121 | 117 | |
| 122 | export class SqliteEventStore implements OrchestrationEventStore { | |
| 123 | private readonly runtime: ManagedRuntime.ManagedRuntime<SqlClientService, unknown>; | |
| 124 | private readonly migrated: Promise<void>; | |
| 125 | private closed = false; | |
| 126 | ||
| 127 | constructor(dbPath: string) { | |
| 128 | fs.mkdirSync(path.dirname(dbPath), { recursive: true }); | |
| 129 | this.runtime = ManagedRuntime.make( | |
| 130 | SqliteClient.layer({ | |
| 131 | filename: dbPath, | |
| 132 | }), | |
| 133 | ); | |
| 134 | this.migrated = this.runtime.runPromise(this.migrate()).then(() => undefined); | |
| 135 | } | |
| 136 | ||
| 137 | private provideSql<A, E>( | |
| 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>( | |
| 138 | 131 | effect: Effect.Effect<A, E, SqlClientService>, |
| 139 | ): Effect.Effect<A, E | unknown> { | |
| 140 | return Effect.provide(effect, this.runtime); | |
| 141 | } | |
| 142 | ||
| 143 | private ensureMigrated(): Effect.Effect<void> { | |
| 144 | return Effect.promise(() => this.migrated); | |
| 145 | } | |
| 146 | ||
| 147 | private migrate(): Effect.Effect<void, unknown, SqlClientService> { | |
| 148 | return Effect.gen(function* () { | |
| 149 | const sql = yield* SqlClient.SqlClient; | |
| 150 | yield* sql`PRAGMA journal_mode = WAL;`; | |
| 151 | yield* sql`PRAGMA foreign_keys = ON;`; | |
| 152 | yield* sql` | |
| 153 | CREATE TABLE IF NOT EXISTS orchestration_events ( | |
| 154 | sequence INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 155 | event_id TEXT NOT NULL UNIQUE, | |
| 156 | event_type TEXT NOT NULL, | |
| 157 | aggregate_type TEXT NOT NULL, | |
| 158 | aggregate_id TEXT NOT NULL, | |
| 159 | occurred_at TEXT NOT NULL, | |
| 160 | command_id TEXT, | |
| 161 | payload_json TEXT NOT NULL | |
| 162 | ) | |
| 163 | `; | |
| 164 | yield* sql` | |
| 165 | CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate | |
| 166 | ON orchestration_events(aggregate_type, aggregate_id, sequence) | |
| 167 | `; | |
| 168 | }); | |
| 169 | } | |
| 170 | ||
| 171 | append(event: Omit<OrchestrationEvent, "sequence">): Effect.Effect<OrchestrationEvent> { | |
| 172 | return Effect.gen(this, function* () { | |
| 173 | yield* this.ensureMigrated(); | |
| 174 | const row = yield* this.provideSql( | |
| 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( | |
| 175 | 148 | appendEventRow({ |
| 176 | 149 | eventId: event.eventId, |
| 177 | 150 | type: event.type, |
| ... | ... | @@ -184,12 +157,14 @@ export class SqliteEventStore implements OrchestrationEventStore { |
| 184 | 157 | ); |
| 185 | 158 | return eventRowToOrchestrationEvent(row); |
| 186 | 159 | }).pipe(Effect.orDie); |
| 187 | } | |
| 188 | 160 | |
| 189 | readFromSequence(sequenceExclusive: number, limit = 1_000): Effect.Effect<OrchestrationEvent[]> { | |
| 190 | return Effect.gen(this, function* () { | |
| 191 | yield* this.ensureMigrated(); | |
| 192 | const rows = yield* this.provideSql( | |
| 161 | const readFromSequence: OrchestrationEventRepositoryShape["readFromSequence"] = ( | |
| 162 | sequenceExclusive, | |
| 163 | limit = 1_000, | |
| 164 | ) => | |
| 165 | Effect.gen(function* () { | |
| 166 | yield* ensureInitialized; | |
| 167 | const rows = yield* provideSql( | |
| 193 | 168 | readEventRowsFromSequence({ |
| 194 | 169 | sequenceExclusive, |
| 195 | 170 | limit, |
| ... | ... | @@ -197,17 +172,19 @@ export class SqliteEventStore implements OrchestrationEventStore { |
| 197 | 172 | ); |
| 198 | 173 | return rows.map(eventRowToOrchestrationEvent); |
| 199 | 174 | }).pipe(Effect.orDie); |
| 200 | } | |
| 201 | ||
| 202 | readAll(): Effect.Effect<OrchestrationEvent[]> { | |
| 203 | return this.readFromSequence(0, Number.MAX_SAFE_INTEGER); | |
| 204 | } | |
| 205 | 175 | |
| 206 | close(): void { | |
| 207 | if (this.closed) { | |
| 176 | const close = (): void => { | |
| 177 | if (closed) { | |
| 208 | 178 | return; |
| 209 | 179 | } |
| 210 | this.closed = true; | |
| 211 | void this.runtime.dispose(); | |
| 212 | } | |
| 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 | }; | |
| 213 | 190 | } |
apps/server/src/orchestration/layers.ts+10-11| ... | ... | @@ -2,27 +2,26 @@ import path from "node:path"; |
| 2 | 2 | |
| 3 | 3 | import { Effect, Layer } from "effect"; |
| 4 | 4 | |
| 5 | import { OrchestrationEventRepository } from "./eventRepository"; | |
| 5 | 6 | import { OrchestrationEngine } from "./engine"; |
| 6 | import { SqliteEventStore } from "./eventStore"; | |
| 7 | import { | |
| 8 | OrchestrationConfig, | |
| 9 | OrchestrationEngineService, | |
| 10 | OrchestrationEventStoreService, | |
| 11 | } from "./services"; | |
| 7 | import { makeSqliteOrchestrationEventRepository } from "./eventStore"; | |
| 8 | import { OrchestrationConfig, OrchestrationEngineService } from "./services"; | |
| 12 | 9 | |
| 13 | export const OrchestrationEventStoreLive = Layer.effect( | |
| 14 | OrchestrationEventStoreService, | |
| 10 | export const OrchestrationEventRepositoryLive = Layer.effect( | |
| 11 | OrchestrationEventRepository, | |
| 15 | 12 | Effect.map(OrchestrationConfig, ({ stateDir }) => { |
| 16 | 13 | const dbPath = path.join(stateDir, "orchestration.sqlite"); |
| 17 | return new SqliteEventStore(dbPath); | |
| 14 | return makeSqliteOrchestrationEventRepository(dbPath); | |
| 18 | 15 | }), |
| 19 | 16 | ); |
| 20 | 17 | |
| 21 | 18 | export const OrchestrationEngineLive = Layer.effect( |
| 22 | 19 | OrchestrationEngineService, |
| 23 | Effect.map(OrchestrationEventStoreService, (eventStore) => new OrchestrationEngine(eventStore)), | |
| 20 | Effect.map(OrchestrationEventRepository, (eventRepository) => { | |
| 21 | return new OrchestrationEngine(eventRepository); | |
| 22 | }), | |
| 24 | 23 | ); |
| 25 | 24 | |
| 26 | 25 | export const OrchestrationLive = OrchestrationEngineLive.pipe( |
| 27 | Layer.provide(OrchestrationEventStoreLive), | |
| 26 | Layer.provide(OrchestrationEventRepositoryLive), | |
| 28 | 27 | ); |
apps/server/src/orchestration/migrations.ts created+31| ... | ... | @@ -0,0 +1,31 @@ |
| 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/orchestration/services.ts-5| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | import { Context } from "effect"; |
| 2 | 2 | |
| 3 | 3 | import { OrchestrationEngine } from "./engine"; |
| 4 | import type { OrchestrationEventStore } from "./eventStore"; | |
| 5 | 4 | |
| 6 | 5 | export interface OrchestrationConfigShape { |
| 7 | 6 | readonly stateDir: string; |
| ... | ... | @@ -12,10 +11,6 @@ export class OrchestrationConfig extends Context.Tag("orchestration/Config")< |
| 12 | 11 | OrchestrationConfigShape |
| 13 | 12 | >() {} |
| 14 | 13 | |
| 15 | export class OrchestrationEventStoreService extends Context.Tag( | |
| 16 | "orchestration/EventStore", | |
| 17 | )<OrchestrationEventStoreService, OrchestrationEventStore>() {} | |
| 18 | ||
| 19 | 14 | export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< |
| 20 | 15 | OrchestrationEngineService, |
| 21 | 16 | OrchestrationEngine |