| author | |
| committer | |
| log | b152462b2cd5f26f40ae174765ede2c62381d282 |
| tree | 3fd5a6879fa53d5d21cf33ebf2beb79364272287 |
| parent | e219efa22a4a8a6cc1424af54b04c58be9a8b68d |
11 files changed, 846 insertions(+), 260 deletions(-)
apps/server/src/orchestration/Errors.ts created+95| ... | ... | @@ -0,0 +1,95 @@ |
| 1 | import { ParseResult, Schema } from "effect"; | |
| 2 | ||
| 3 | import type { OrchestrationEventRepositoryError } from "../persistence/Errors"; | |
| 4 | ||
| 5 | export class OrchestrationCommandJsonParseError extends Schema.TaggedError<OrchestrationCommandJsonParseError>()( | |
| 6 | "OrchestrationCommandJsonParseError", | |
| 7 | { | |
| 8 | detail: Schema.String, | |
| 9 | cause: Schema.optional(Schema.Defect), | |
| 10 | }, | |
| 11 | ) { | |
| 12 | override get message(): string { | |
| 13 | return `Invalid orchestration command JSON: ${this.detail}`; | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | export class OrchestrationCommandDecodeError extends Schema.TaggedError<OrchestrationCommandDecodeError>()( | |
| 18 | "OrchestrationCommandDecodeError", | |
| 19 | { | |
| 20 | issue: Schema.String, | |
| 21 | cause: Schema.optional(Schema.Defect), | |
| 22 | }, | |
| 23 | ) { | |
| 24 | override get message(): string { | |
| 25 | return `Invalid orchestration command payload: ${this.issue}`; | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | export class OrchestrationReducerDecodeError extends Schema.TaggedError<OrchestrationReducerDecodeError>()( | |
| 30 | "OrchestrationReducerDecodeError", | |
| 31 | { | |
| 32 | eventType: Schema.String, | |
| 33 | issue: Schema.String, | |
| 34 | cause: Schema.optional(Schema.Defect), | |
| 35 | }, | |
| 36 | ) { | |
| 37 | override get message(): string { | |
| 38 | return `Reducer decode failed for ${this.eventType}: ${this.issue}`; | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | export class OrchestrationListenerCallbackError extends Schema.TaggedError<OrchestrationListenerCallbackError>()( | |
| 43 | "OrchestrationListenerCallbackError", | |
| 44 | { | |
| 45 | listener: Schema.Literal("read-model", "domain-event"), | |
| 46 | detail: Schema.String, | |
| 47 | cause: Schema.optional(Schema.Defect), | |
| 48 | }, | |
| 49 | ) { | |
| 50 | override get message(): string { | |
| 51 | return `Orchestration ${this.listener} listener failed: ${this.detail}`; | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | export type OrchestrationDispatchError = | |
| 56 | | OrchestrationEventRepositoryError | |
| 57 | | OrchestrationReducerDecodeError | |
| 58 | | OrchestrationListenerCallbackError; | |
| 59 | ||
| 60 | export type OrchestrationEngineError = | |
| 61 | | OrchestrationDispatchError | |
| 62 | | OrchestrationCommandJsonParseError | |
| 63 | | OrchestrationCommandDecodeError; | |
| 64 | ||
| 65 | export function toOrchestrationCommandDecodeError(error: ParseResult.ParseError) { | |
| 66 | return new OrchestrationCommandDecodeError({ | |
| 67 | issue: ParseResult.TreeFormatter.formatErrorSync(error), | |
| 68 | cause: error, | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | export function toReducerDecodeError(eventType: string) { | |
| 73 | return (error: ParseResult.ParseError): OrchestrationReducerDecodeError => | |
| 74 | new OrchestrationReducerDecodeError({ | |
| 75 | eventType, | |
| 76 | issue: ParseResult.TreeFormatter.formatErrorSync(error), | |
| 77 | cause: error, | |
| 78 | }); | |
| 79 | } | |
| 80 | ||
| 81 | export function toOrchestrationJsonParseError(cause: unknown) { | |
| 82 | return new OrchestrationCommandJsonParseError({ | |
| 83 | detail: `Failed to parse orchestration command JSON: ${cause}`, | |
| 84 | cause, | |
| 85 | }); | |
| 86 | } | |
| 87 | ||
| 88 | export function toListenerCallbackError(listener: "read-model" | "domain-event") { | |
| 89 | return (cause: unknown): OrchestrationListenerCallbackError => | |
| 90 | new OrchestrationListenerCallbackError({ | |
| 91 | listener, | |
| 92 | detail: `Failed to invoke orchestration ${listener} listener: ${cause}`, | |
| 93 | cause, | |
| 94 | }); | |
| 95 | } |
apps/server/src/orchestration/Layer.test.ts+7-1| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | OrchestrationEventRepository, |
| 11 | 11 | type OrchestrationEventRepositoryShape, |
| 12 | 12 | } from "../persistence/Services/OrchestrationEvents"; |
| 13 | import { PersistenceSqlError } from "../persistence/Errors"; | |
| 13 | 14 | |
| 14 | 15 | import { Layer, ManagedRuntime } from "effect"; |
| 15 | 16 | |
| ... | ... | @@ -375,7 +376,12 @@ describe("OrchestrationEngine", () => { |
| 375 | 376 | append(event) { |
| 376 | 377 | if (shouldFailFirstAppend) { |
| 377 | 378 | shouldFailFirstAppend = false; |
| 378 | return Effect.die(new Error("append failed")); | |
| 379 | return Effect.fail( | |
| 380 | new PersistenceSqlError({ | |
| 381 | operation: "test.append", | |
| 382 | detail: "append failed", | |
| 383 | }), | |
| 384 | ); | |
| 379 | 385 | } |
| 380 | 386 | const savedEvent = { |
| 381 | 387 | ...event, |
apps/server/src/orchestration/Layer.ts+115-119| ... | ... | @@ -4,33 +4,24 @@ import type { |
| 4 | 4 | OrchestrationReadModel, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { OrchestrationCommandSchema } from "@t3tools/contracts"; |
| 7 | import { | |
| 8 | Cause, | |
| 9 | Deferred, | |
| 10 | Effect, | |
| 11 | Either, | |
| 12 | Fiber, | |
| 13 | Layer, | |
| 14 | PubSub, | |
| 15 | Queue, | |
| 16 | Runtime, | |
| 17 | Schema, | |
| 18 | } from "effect"; | |
| 7 | import { Deferred, Effect, Layer, PubSub, Queue, Schema } from "effect"; | |
| 19 | 8 | |
| 20 | 9 | import { createLogger } from "../logger"; |
| 21 | 10 | import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; |
| 11 | import { | |
| 12 | toListenerCallbackError, | |
| 13 | toOrchestrationCommandDecodeError, | |
| 14 | toOrchestrationJsonParseError, | |
| 15 | type OrchestrationDispatchError, | |
| 16 | } from "./Errors"; | |
| 22 | 17 | import { createEmptyReadModel, reduceEvent } from "./reducer"; |
| 23 | 18 | import { OrchestrationEngineService, type OrchestrationEngineShape } from "./Service"; |
| 24 | 19 | |
| 25 | 20 | type CommandEnvelope = { |
| 26 | 21 | command: OrchestrationCommand; |
| 27 | result: Deferred.Deferred<{ sequence: number }, Error>; | |
| 22 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; | |
| 28 | 23 | }; |
| 29 | 24 | |
| 30 | function asError(error: unknown, fallbackMessage: string): Error { | |
| 31 | return error instanceof Error ? error : new Error(fallbackMessage); | |
| 32 | } | |
| 33 | ||
| 34 | 25 | function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEvent, "sequence"> { |
| 35 | 26 | const eventId = crypto.randomUUID(); |
| 36 | 27 | switch (command.type) { |
| ... | ... | @@ -169,129 +160,134 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEve |
| 169 | 160 | } |
| 170 | 161 | } |
| 171 | 162 | |
| 172 | const decodeUnknownCommand = Schema.decodeUnknownEither(OrchestrationCommandSchema); | |
| 163 | const decodeUnknownCommand = Schema.decodeUnknown(OrchestrationCommandSchema); | |
| 173 | 164 | |
| 174 | export const makeOrchestrationEngine = Effect.gen(function* () { | |
| 175 | const logger = createLogger("orchestration"); | |
| 176 | const eventStore = yield* OrchestrationEventRepository; | |
| 177 | const runtime = Runtime.defaultRuntime; | |
| 165 | const makeOrchestrationEngine = () => | |
| 166 | Effect.gen(function* () { | |
| 167 | const logger = createLogger("orchestration"); | |
| 168 | const eventStore = yield* OrchestrationEventRepository; | |
| 178 | 169 | |
| 179 | let readModel = createEmptyReadModel(new Date().toISOString()); | |
| 170 | let readModel = createEmptyReadModel(new Date().toISOString()); | |
| 180 | 171 | |
| 181 | const commandQueue = yield* Queue.unbounded<CommandEnvelope>(); | |
| 182 | const readModelPubSub = yield* PubSub.unbounded<OrchestrationReadModel>(); | |
| 183 | const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>(); | |
| 172 | const commandQueue = yield* Queue.unbounded<CommandEnvelope>(); | |
| 173 | const readModelPubSub = yield* PubSub.unbounded<OrchestrationReadModel>(); | |
| 174 | const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>(); | |
| 184 | 175 | |
| 185 | const readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); | |
| 186 | const domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); | |
| 176 | const readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); | |
| 177 | const domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); | |
| 187 | 178 | |
| 188 | const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => | |
| 189 | Effect.gen(function* () { | |
| 190 | const eventBase = mapCommandToEvent(envelope.command); | |
| 191 | const savedEvent = yield* eventStore.append(eventBase); | |
| 192 | readModel = yield* reduceEvent(readModel, savedEvent); | |
| 179 | const notifyReadModelListeners = (snapshot: OrchestrationReadModel) => | |
| 180 | Effect.forEach(readModelListeners, (listener) => | |
| 181 | Effect.try({ | |
| 182 | try: () => listener(snapshot), | |
| 183 | catch: toListenerCallbackError("read-model"), | |
| 184 | }), | |
| 185 | ).pipe(Effect.asVoid); | |
| 193 | 186 | |
| 194 | const snapshot = readModel; | |
| 195 | yield* Effect.all([ | |
| 196 | PubSub.publish(eventPubSub, savedEvent), | |
| 197 | PubSub.publish(readModelPubSub, snapshot), | |
| 198 | ]); | |
| 187 | const notifyDomainEventListeners = (event: OrchestrationEvent) => | |
| 188 | Effect.forEach(domainEventListeners, (listener) => | |
| 189 | Effect.try({ | |
| 190 | try: () => listener(event), | |
| 191 | catch: toListenerCallbackError("domain-event"), | |
| 192 | }), | |
| 193 | ).pipe(Effect.asVoid); | |
| 199 | 194 | |
| 200 | for (const listener of domainEventListeners) { | |
| 201 | listener(savedEvent); | |
| 202 | } | |
| 203 | for (const listener of readModelListeners) { | |
| 204 | listener(snapshot); | |
| 205 | } | |
| 195 | const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => | |
| 196 | Effect.gen(function* () { | |
| 197 | const eventBase = mapCommandToEvent(envelope.command); | |
| 198 | const savedEvent = yield* eventStore.append(eventBase); | |
| 199 | readModel = yield* reduceEvent(readModel, savedEvent); | |
| 206 | 200 | |
| 207 | yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); | |
| 208 | }).pipe( | |
| 209 | Effect.catchAllCause((cause) => | |
| 210 | Deferred.fail( | |
| 211 | envelope.result, | |
| 212 | asError(Cause.squash(cause), "Unknown command processing error"), | |
| 213 | ).pipe(Effect.asVoid), | |
| 214 | ), | |
| 215 | ); | |
| 201 | const snapshot = readModel; | |
| 202 | yield* Effect.all([ | |
| 203 | PubSub.publish(eventPubSub, savedEvent), | |
| 204 | PubSub.publish(readModelPubSub, snapshot), | |
| 205 | ]); | |
| 216 | 206 | |
| 217 | const bootstrapReadModel: Effect.Effect<void, unknown> = Effect.gen(function* () { | |
| 218 | const existingEvents = yield* eventStore.readAll(); | |
| 219 | for (const event of existingEvents) { | |
| 220 | readModel = yield* reduceEvent(readModel, event); | |
| 221 | } | |
| 222 | }); | |
| 207 | yield* notifyDomainEventListeners(savedEvent); | |
| 208 | yield* notifyReadModelListeners(snapshot); | |
| 223 | 209 | |
| 224 | yield* bootstrapReadModel; | |
| 210 | yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); | |
| 211 | }).pipe( | |
| 212 | Effect.catchAll((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid)), | |
| 213 | ); | |
| 225 | 214 | |
| 226 | const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); | |
| 227 | const workerFiber: Fiber.RuntimeFiber<void, unknown> = Runtime.runFork(runtime)(worker); | |
| 228 | logger.info("orchestration engine started", { | |
| 229 | sequence: readModel.sequence, | |
| 230 | }); | |
| 215 | const bootstrapReadModel: Effect.Effect<void, OrchestrationDispatchError> = Effect.gen( | |
| 216 | function* () { | |
| 217 | const existingEvents = yield* eventStore.readAll(); | |
| 218 | for (const event of existingEvents) { | |
| 219 | readModel = yield* reduceEvent(readModel, event); | |
| 220 | } | |
| 221 | }, | |
| 222 | ); | |
| 223 | ||
| 224 | yield* bootstrapReadModel; | |
| 231 | 225 | |
| 232 | yield* Effect.addFinalizer(() => Fiber.interrupt(workerFiber).pipe(Effect.orDie)); | |
| 226 | const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); | |
| 227 | yield* Effect.forkScoped(worker); | |
| 228 | logger.info("orchestration engine started", { | |
| 229 | sequence: readModel.sequence, | |
| 230 | }); | |
| 233 | 231 | |
| 234 | const getSnapshot: OrchestrationEngineShape["getSnapshot"] = () => | |
| 235 | Effect.sync((): OrchestrationReadModel => readModel); | |
| 232 | const getSnapshot: OrchestrationEngineShape["getSnapshot"] = () => | |
| 233 | Effect.sync((): OrchestrationReadModel => readModel); | |
| 236 | 234 | |
| 237 | const replayEvents: OrchestrationEngineShape["replayEvents"] = (fromSequenceExclusive) => | |
| 238 | eventStore.readFromSequence(fromSequenceExclusive); | |
| 235 | const replayEvents: OrchestrationEngineShape["replayEvents"] = (fromSequenceExclusive) => | |
| 236 | eventStore.readFromSequence(fromSequenceExclusive); | |
| 239 | 237 | |
| 240 | const dispatch: OrchestrationEngineShape["dispatch"] = (command) => | |
| 241 | Effect.gen(function* () { | |
| 242 | const result = yield* Deferred.make<{ sequence: number }, Error>(); | |
| 243 | yield* Queue.offer(commandQueue, { command, result }); | |
| 244 | return yield* Deferred.await(result); | |
| 245 | }).pipe(Effect.mapError((error) => asError(error, "Queue offer failed"))); | |
| 238 | const dispatch: OrchestrationEngineShape["dispatch"] = (command) => | |
| 239 | Effect.gen(function* () { | |
| 240 | const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); | |
| 241 | yield* Queue.offer(commandQueue, { command, result }); | |
| 242 | return yield* Deferred.await(result); | |
| 243 | }); | |
| 246 | 244 | |
| 247 | const dispatchUnknown: OrchestrationEngineShape["dispatchUnknown"] = (command) => | |
| 248 | Effect.gen(function* () { | |
| 249 | let payload: unknown = command; | |
| 250 | if (typeof command === "string") { | |
| 251 | try { | |
| 252 | payload = JSON.parse(command) as unknown; | |
| 253 | } catch { | |
| 254 | return yield* Effect.fail( | |
| 255 | new Error("Invalid orchestration command: payload is not valid JSON"), | |
| 256 | ); | |
| 257 | } | |
| 258 | } | |
| 259 | const decoded = decodeUnknownCommand(payload); | |
| 260 | if (Either.isLeft(decoded)) { | |
| 261 | return yield* Effect.fail( | |
| 262 | new Error(`Invalid orchestration command: ${decoded.left.toString()}`), | |
| 245 | const dispatchUnknown: OrchestrationEngineShape["dispatchUnknown"] = (command) => | |
| 246 | Effect.gen(function* () { | |
| 247 | const payload = | |
| 248 | typeof command === "string" | |
| 249 | ? yield* Effect.try({ | |
| 250 | try: () => JSON.parse(command) as unknown, | |
| 251 | catch: toOrchestrationJsonParseError, | |
| 252 | }) | |
| 253 | : command; | |
| 254 | ||
| 255 | const decoded = yield* decodeUnknownCommand(payload).pipe( | |
| 256 | Effect.mapError(toOrchestrationCommandDecodeError), | |
| 263 | 257 | ); |
| 264 | } | |
| 265 | return yield* dispatch(decoded.right); | |
| 266 | }); | |
| 267 | 258 | |
| 268 | const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) => | |
| 269 | Effect.sync(() => { | |
| 270 | readModelListeners.add(callback); | |
| 271 | return () => { | |
| 272 | readModelListeners.delete(callback); | |
| 273 | }; | |
| 274 | }); | |
| 259 | return yield* dispatch(decoded); | |
| 260 | }); | |
| 275 | 261 | |
| 276 | const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = (callback) => | |
| 277 | Effect.sync(() => { | |
| 278 | domainEventListeners.add(callback); | |
| 279 | return () => { | |
| 280 | domainEventListeners.delete(callback); | |
| 281 | }; | |
| 282 | }); | |
| 262 | const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) => | |
| 263 | Effect.sync(() => { | |
| 264 | readModelListeners.add(callback); | |
| 265 | return () => { | |
| 266 | readModelListeners.delete(callback); | |
| 267 | }; | |
| 268 | }); | |
| 283 | 269 | |
| 284 | return { | |
| 285 | getSnapshot, | |
| 286 | replayEvents, | |
| 287 | dispatchUnknown, | |
| 288 | dispatch, | |
| 289 | subscribeToReadModel, | |
| 290 | subscribeToDomainEvents, | |
| 291 | } satisfies OrchestrationEngineShape; | |
| 292 | }); | |
| 270 | const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = ( | |
| 271 | callback, | |
| 272 | ) => | |
| 273 | Effect.sync(() => { | |
| 274 | domainEventListeners.add(callback); | |
| 275 | return () => { | |
| 276 | domainEventListeners.delete(callback); | |
| 277 | }; | |
| 278 | }); | |
| 279 | ||
| 280 | return { | |
| 281 | getSnapshot, | |
| 282 | replayEvents, | |
| 283 | dispatchUnknown, | |
| 284 | dispatch, | |
| 285 | subscribeToReadModel, | |
| 286 | subscribeToDomainEvents, | |
| 287 | } satisfies OrchestrationEngineShape; | |
| 288 | }); | |
| 293 | 289 | |
| 294 | 290 | export const OrchestrationEngineLive = Layer.scoped( |
| 295 | 291 | OrchestrationEngineService, |
| 296 | makeOrchestrationEngine, | |
| 292 | makeOrchestrationEngine(), | |
| 297 | 293 | ); |
apps/server/src/orchestration/Service.ts+73-3| ... | ... | @@ -1,3 +1,11 @@ |
| 1 | /** | |
| 2 | * OrchestrationEngineService - Service interface for orchestration command handling. | |
| 3 | * | |
| 4 | * Uses Effect `Context.Tag` for dependency injection. Command dispatch, | |
| 5 | * replay, and unknown-input decoding all return typed domain errors. | |
| 6 | * | |
| 7 | * @module OrchestrationEngineService | |
| 8 | */ | |
| 1 | 9 | import type { |
| 2 | 10 | OrchestrationCommand, |
| 3 | 11 | OrchestrationEvent, |
| ... | ... | @@ -6,19 +14,81 @@ import type { |
| 6 | 14 | import { Context } from "effect"; |
| 7 | 15 | import type { Effect } from "effect"; |
| 8 | 16 | |
| 17 | import type { OrchestrationDispatchError, OrchestrationEngineError } from "./Errors"; | |
| 18 | import type { OrchestrationEventRepositoryError } from "../persistence/Errors"; | |
| 19 | ||
| 9 | 20 | export interface OrchestrationEngineShape { |
| 21 | /** | |
| 22 | * Read the current in-memory orchestration snapshot. | |
| 23 | * | |
| 24 | * @returns Effect containing the latest read model. | |
| 25 | */ | |
| 10 | 26 | readonly getSnapshot: () => Effect.Effect<OrchestrationReadModel>; |
| 11 | readonly replayEvents: (fromSequenceExclusive: number) => Effect.Effect<OrchestrationEvent[]>; | |
| 12 | readonly dispatchUnknown: (command: unknown) => Effect.Effect<{ sequence: number }, Error>; | |
| 13 | readonly dispatch: (command: OrchestrationCommand) => Effect.Effect<{ sequence: number }, Error>; | |
| 27 | ||
| 28 | /** | |
| 29 | * Replay persisted orchestration events from an exclusive sequence cursor. | |
| 30 | * | |
| 31 | * @param fromSequenceExclusive - Sequence cursor (exclusive). | |
| 32 | * @returns Effect containing ordered events. | |
| 33 | */ | |
| 34 | readonly replayEvents: ( | |
| 35 | fromSequenceExclusive: number, | |
| 36 | ) => Effect.Effect<OrchestrationEvent[], OrchestrationEventRepositoryError>; | |
| 37 | ||
| 38 | /** | |
| 39 | * Validate and dispatch an unknown command payload. | |
| 40 | * | |
| 41 | * Accepts either parsed values or raw JSON strings. | |
| 42 | * | |
| 43 | * @param command - Unknown command payload. | |
| 44 | * @returns Effect containing the sequence of the persisted event. | |
| 45 | */ | |
| 46 | readonly dispatchUnknown: ( | |
| 47 | command: unknown, | |
| 48 | ) => Effect.Effect<{ sequence: number }, OrchestrationEngineError>; | |
| 49 | ||
| 50 | /** | |
| 51 | * Dispatch a validated orchestration command. | |
| 52 | * | |
| 53 | * @param command - Valid orchestration command. | |
| 54 | * @returns Effect containing the sequence of the persisted event. | |
| 55 | */ | |
| 56 | readonly dispatch: ( | |
| 57 | command: OrchestrationCommand, | |
| 58 | ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError>; | |
| 59 | ||
| 60 | /** | |
| 61 | * Subscribe to read model updates. | |
| 62 | * | |
| 63 | * @param callback - Handler invoked on every read model update. | |
| 64 | * @returns Effect containing an unsubscribe function. | |
| 65 | */ | |
| 14 | 66 | readonly subscribeToReadModel: ( |
| 15 | 67 | callback: (snapshot: OrchestrationReadModel) => void, |
| 16 | 68 | ) => Effect.Effect<() => void>; |
| 69 | ||
| 70 | /** | |
| 71 | * Subscribe to domain event fan-out. | |
| 72 | * | |
| 73 | * @param callback - Handler invoked for every persisted domain event. | |
| 74 | * @returns Effect containing an unsubscribe function. | |
| 75 | */ | |
| 17 | 76 | readonly subscribeToDomainEvents: ( |
| 18 | 77 | callback: (event: OrchestrationEvent) => void, |
| 19 | 78 | ) => Effect.Effect<() => void>; |
| 20 | 79 | } |
| 21 | 80 | |
| 81 | /** | |
| 82 | * OrchestrationEngineService - Context tag for orchestration engine access. | |
| 83 | * | |
| 84 | * @example | |
| 85 | * ```ts | |
| 86 | * const program = Effect.gen(function* () { | |
| 87 | * const engine = yield* OrchestrationEngineService | |
| 88 | * return yield* engine.getSnapshot() | |
| 89 | * }) | |
| 90 | * ``` | |
| 91 | */ | |
| 22 | 92 | export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< |
| 23 | 93 | OrchestrationEngineService, |
| 24 | 94 | OrchestrationEngineShape |
apps/server/src/orchestration/reducer.ts+113-66| ... | ... | @@ -9,7 +9,6 @@ import type { |
| 9 | 9 | import { |
| 10 | 10 | OrchestrationGitReadModelSchema, |
| 11 | 11 | OrchestrationMessageSchema, |
| 12 | OrchestrationReadModelSchema, | |
| 13 | 12 | OrchestrationSessionSchema, |
| 14 | 13 | OrchestrationThreadSchema, |
| 15 | 14 | OrchestrationTurnDiffFileSchema, |
| ... | ... | @@ -17,6 +16,8 @@ import { |
| 17 | 16 | } from "@t3tools/contracts"; |
| 18 | 17 | import { Effect, Schema } from "effect"; |
| 19 | 18 | |
| 19 | import { toReducerDecodeError, type OrchestrationReducerDecodeError } from "./Errors"; | |
| 20 | ||
| 20 | 21 | type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; |
| 21 | 22 | |
| 22 | 23 | function updateThread( |
| ... | ... | @@ -27,12 +28,16 @@ function updateThread( |
| 27 | 28 | return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); |
| 28 | 29 | } |
| 29 | 30 | |
| 30 | const decodeReadModelSync = Schema.decodeUnknownSync(OrchestrationReadModelSchema); | |
| 31 | const decodeMessage = Schema.decodeUnknown(OrchestrationMessageSchema); | |
| 32 | const decodeSession = Schema.decodeUnknown(OrchestrationSessionSchema); | |
| 33 | const decodeThread = Schema.decodeUnknown(OrchestrationThreadSchema); | |
| 34 | const decodeGitReadModel = Schema.decodeUnknown(OrchestrationGitReadModelSchema); | |
| 35 | const decodeTurnDiffSummary = Schema.decodeUnknown(OrchestrationTurnDiffSummarySchema); | |
| 31 | function decodeForEvent<A>( | |
| 32 | schema: Schema.Schema<A>, | |
| 33 | value: unknown, | |
| 34 | eventType: string, | |
| 35 | field: string, | |
| 36 | ): Effect.Effect<A, OrchestrationReducerDecodeError> { | |
| 37 | return Schema.decodeUnknown(schema)(value).pipe( | |
| 38 | Effect.mapError(toReducerDecodeError(`${eventType}:${field}`)), | |
| 39 | ); | |
| 40 | } | |
| 36 | 41 | |
| 37 | 42 | const ThreadCreatedPayloadSchema = Schema.Struct({ |
| 38 | 43 | id: Schema.String, |
| ... | ... | @@ -44,7 +49,6 @@ const ThreadCreatedPayloadSchema = Schema.Struct({ |
| 44 | 49 | createdAt: Schema.String, |
| 45 | 50 | updatedAt: Schema.String, |
| 46 | 51 | }); |
| 47 | const decodeThreadCreatedPayload = Schema.decodeUnknown(ThreadCreatedPayloadSchema); | |
| 48 | 52 | |
| 49 | 53 | const ThreadMetaUpdatedPayloadSchema = Schema.Struct({ |
| 50 | 54 | threadId: Schema.String, |
| ... | ... | @@ -54,7 +58,6 @@ const ThreadMetaUpdatedPayloadSchema = Schema.Struct({ |
| 54 | 58 | worktreePath: Schema.optional(Schema.NullOr(Schema.String)), |
| 55 | 59 | updatedAt: Schema.String, |
| 56 | 60 | }); |
| 57 | const decodeThreadMetaUpdatedPayload = Schema.decodeUnknown(ThreadMetaUpdatedPayloadSchema); | |
| 58 | 61 | |
| 59 | 62 | const MessageSentPayloadSchema = Schema.Struct({ |
| 60 | 63 | id: Schema.String, |
| ... | ... | @@ -64,13 +67,11 @@ const MessageSentPayloadSchema = Schema.Struct({ |
| 64 | 67 | createdAt: Schema.String, |
| 65 | 68 | streaming: Schema.Boolean, |
| 66 | 69 | }); |
| 67 | const decodeMessageSentPayload = Schema.decodeUnknown(MessageSentPayloadSchema); | |
| 68 | 70 | |
| 69 | 71 | const ThreadSessionSetPayloadSchema = Schema.Struct({ |
| 70 | 72 | threadId: Schema.String, |
| 71 | 73 | session: OrchestrationSessionSchema, |
| 72 | 74 | }); |
| 73 | const decodeThreadSessionSetPayload = Schema.decodeUnknown(ThreadSessionSetPayloadSchema); | |
| 74 | 75 | |
| 75 | 76 | const ThreadTurnDiffCompletedPayloadSchema = Schema.Struct({ |
| 76 | 77 | threadId: Schema.String, |
| ... | ... | @@ -81,30 +82,26 @@ const ThreadTurnDiffCompletedPayloadSchema = Schema.Struct({ |
| 81 | 82 | assistantMessageId: Schema.optional(Schema.String), |
| 82 | 83 | checkpointTurnCount: Schema.optional(Schema.Number), |
| 83 | 84 | }); |
| 84 | const decodeThreadTurnDiffCompletedPayload = Schema.decodeUnknown( | |
| 85 | ThreadTurnDiffCompletedPayloadSchema, | |
| 86 | ); | |
| 87 | 85 | |
| 88 | 86 | const ThreadRevertedPayloadSchema = Schema.Struct({ |
| 89 | 87 | threadId: Schema.String, |
| 90 | 88 | turnCount: Schema.Number, |
| 91 | 89 | messageCount: Schema.Number, |
| 92 | 90 | }); |
| 93 | const decodeThreadRevertedPayload = Schema.decodeUnknown(ThreadRevertedPayloadSchema); | |
| 94 | 91 | |
| 95 | 92 | export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { |
| 96 | return decodeReadModelSync({ | |
| 93 | return { | |
| 97 | 94 | sequence: 0, |
| 98 | 95 | threads: [], |
| 99 | 96 | gitByProjectId: {}, |
| 100 | 97 | updatedAt: nowIso, |
| 101 | }); | |
| 98 | }; | |
| 102 | 99 | } |
| 103 | 100 | |
| 104 | 101 | export function reduceEvent( |
| 105 | 102 | model: OrchestrationReadModel, |
| 106 | 103 | event: OrchestrationEvent, |
| 107 | ): Effect.Effect<OrchestrationReadModel, unknown> { | |
| 104 | ): Effect.Effect<OrchestrationReadModel, OrchestrationReducerDecodeError> { | |
| 108 | 105 | const nextBase: OrchestrationReadModel = { |
| 109 | 106 | ...model, |
| 110 | 107 | sequence: event.sequence, |
| ... | ... | @@ -114,25 +111,35 @@ export function reduceEvent( |
| 114 | 111 | switch (event.type) { |
| 115 | 112 | case "thread.created": |
| 116 | 113 | return Effect.gen(function* () { |
| 117 | const payload = yield* decodeThreadCreatedPayload(event.payload); | |
| 118 | const thread: OrchestrationThread = yield* decodeThread({ | |
| 119 | id: payload.id, | |
| 120 | projectId: payload.projectId, | |
| 121 | title: payload.title, | |
| 122 | model: payload.model, | |
| 123 | branch: payload.branch, | |
| 124 | worktreePath: payload.worktreePath, | |
| 125 | createdAt: payload.createdAt, | |
| 126 | updatedAt: payload.updatedAt, | |
| 127 | latestTurnId: null, | |
| 128 | latestTurnStartedAt: null, | |
| 129 | latestTurnCompletedAt: null, | |
| 130 | latestTurnDurationMs: null, | |
| 131 | messages: [], | |
| 132 | session: null, | |
| 133 | turnDiffSummaries: [], | |
| 134 | error: null, | |
| 135 | }); | |
| 114 | const payload = yield* decodeForEvent( | |
| 115 | ThreadCreatedPayloadSchema, | |
| 116 | event.payload, | |
| 117 | event.type, | |
| 118 | "payload", | |
| 119 | ); | |
| 120 | const thread: OrchestrationThread = yield* decodeForEvent( | |
| 121 | OrchestrationThreadSchema, | |
| 122 | { | |
| 123 | id: payload.id, | |
| 124 | projectId: payload.projectId, | |
| 125 | title: payload.title, | |
| 126 | model: payload.model, | |
| 127 | branch: payload.branch, | |
| 128 | worktreePath: payload.worktreePath, | |
| 129 | createdAt: payload.createdAt, | |
| 130 | updatedAt: payload.updatedAt, | |
| 131 | latestTurnId: null, | |
| 132 | latestTurnStartedAt: null, | |
| 133 | latestTurnCompletedAt: null, | |
| 134 | latestTurnDurationMs: null, | |
| 135 | messages: [], | |
| 136 | session: null, | |
| 137 | turnDiffSummaries: [], | |
| 138 | error: null, | |
| 139 | }, | |
| 140 | event.type, | |
| 141 | "thread", | |
| 142 | ); | |
| 136 | 143 | const existing = nextBase.threads.find((entry) => entry.id === thread.id); |
| 137 | 144 | return { |
| 138 | 145 | ...nextBase, |
| ... | ... | @@ -147,7 +154,12 @@ export function reduceEvent( |
| 147 | 154 | threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId), |
| 148 | 155 | }); |
| 149 | 156 | case "thread.meta-updated": |
| 150 | return decodeThreadMetaUpdatedPayload(event.payload).pipe( | |
| 157 | return decodeForEvent( | |
| 158 | ThreadMetaUpdatedPayloadSchema, | |
| 159 | event.payload, | |
| 160 | event.type, | |
| 161 | "payload", | |
| 162 | ).pipe( | |
| 151 | 163 | Effect.map((payload) => { |
| 152 | 164 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); |
| 153 | 165 | if (!thread) { |
| ... | ... | @@ -167,19 +179,29 @@ export function reduceEvent( |
| 167 | 179 | ); |
| 168 | 180 | case "message.sent": |
| 169 | 181 | return Effect.gen(function* () { |
| 170 | const payload = yield* decodeMessageSentPayload(event.payload); | |
| 182 | const payload = yield* decodeForEvent( | |
| 183 | MessageSentPayloadSchema, | |
| 184 | event.payload, | |
| 185 | event.type, | |
| 186 | "payload", | |
| 187 | ); | |
| 171 | 188 | const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId); |
| 172 | 189 | if (!targetThread) { |
| 173 | 190 | return nextBase; |
| 174 | 191 | } |
| 175 | 192 | |
| 176 | const message: OrchestrationMessage = yield* decodeMessage({ | |
| 177 | id: payload.id, | |
| 178 | role: payload.role, | |
| 179 | text: payload.text, | |
| 180 | createdAt: payload.createdAt, | |
| 181 | streaming: payload.streaming, | |
| 182 | }); | |
| 193 | const message: OrchestrationMessage = yield* decodeForEvent( | |
| 194 | OrchestrationMessageSchema, | |
| 195 | { | |
| 196 | id: payload.id, | |
| 197 | role: payload.role, | |
| 198 | text: payload.text, | |
| 199 | createdAt: payload.createdAt, | |
| 200 | streaming: payload.streaming, | |
| 201 | }, | |
| 202 | event.type, | |
| 203 | "message", | |
| 204 | ); | |
| 183 | 205 | const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); |
| 184 | 206 | const nextMessages = existingMessage |
| 185 | 207 | ? targetThread.messages.map((entry) => |
| ... | ... | @@ -208,16 +230,26 @@ export function reduceEvent( |
| 208 | 230 | }); |
| 209 | 231 | case "thread.session-set": |
| 210 | 232 | return Effect.gen(function* () { |
| 211 | const payload = yield* decodeThreadSessionSetPayload(event.payload); | |
| 233 | const payload = yield* decodeForEvent( | |
| 234 | ThreadSessionSetPayloadSchema, | |
| 235 | event.payload, | |
| 236 | event.type, | |
| 237 | "payload", | |
| 238 | ); | |
| 212 | 239 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); |
| 213 | 240 | if (!thread) { |
| 214 | 241 | return nextBase; |
| 215 | 242 | } |
| 216 | 243 | |
| 217 | const session: OrchestrationSession = yield* decodeSession({ | |
| 218 | ...payload.session, | |
| 219 | threadId: payload.threadId, | |
| 220 | }); | |
| 244 | const session: OrchestrationSession = yield* decodeForEvent( | |
| 245 | OrchestrationSessionSchema, | |
| 246 | { | |
| 247 | ...payload.session, | |
| 248 | threadId: payload.threadId, | |
| 249 | }, | |
| 250 | event.type, | |
| 251 | "session", | |
| 252 | ); | |
| 221 | 253 | |
| 222 | 254 | return { |
| 223 | 255 | ...nextBase, |
| ... | ... | @@ -230,24 +262,34 @@ export function reduceEvent( |
| 230 | 262 | }); |
| 231 | 263 | case "thread.turn-diff-completed": |
| 232 | 264 | return Effect.gen(function* () { |
| 233 | const payload = yield* decodeThreadTurnDiffCompletedPayload(event.payload); | |
| 265 | const payload = yield* decodeForEvent( | |
| 266 | ThreadTurnDiffCompletedPayloadSchema, | |
| 267 | event.payload, | |
| 268 | event.type, | |
| 269 | "payload", | |
| 270 | ); | |
| 234 | 271 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); |
| 235 | 272 | if (!thread) { |
| 236 | 273 | return nextBase; |
| 237 | 274 | } |
| 238 | 275 | |
| 239 | const nextSummary = yield* decodeTurnDiffSummary({ | |
| 240 | turnId: payload.turnId, | |
| 241 | completedAt: payload.completedAt, | |
| 242 | ...(payload.status !== undefined ? { status: payload.status } : {}), | |
| 243 | files: payload.files, | |
| 244 | ...(payload.assistantMessageId !== undefined | |
| 245 | ? { assistantMessageId: payload.assistantMessageId } | |
| 246 | : {}), | |
| 247 | ...(payload.checkpointTurnCount !== undefined | |
| 248 | ? { checkpointTurnCount: payload.checkpointTurnCount } | |
| 249 | : {}), | |
| 250 | }); | |
| 276 | const nextSummary = yield* decodeForEvent( | |
| 277 | OrchestrationTurnDiffSummarySchema, | |
| 278 | { | |
| 279 | turnId: payload.turnId, | |
| 280 | completedAt: payload.completedAt, | |
| 281 | ...(payload.status !== undefined ? { status: payload.status } : {}), | |
| 282 | files: payload.files, | |
| 283 | ...(payload.assistantMessageId !== undefined | |
| 284 | ? { assistantMessageId: payload.assistantMessageId } | |
| 285 | : {}), | |
| 286 | ...(payload.checkpointTurnCount !== undefined | |
| 287 | ? { checkpointTurnCount: payload.checkpointTurnCount } | |
| 288 | : {}), | |
| 289 | }, | |
| 290 | event.type, | |
| 291 | "turnDiffSummary", | |
| 292 | ); | |
| 251 | 293 | const turnDiffSummaries = [ |
| 252 | 294 | ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId), |
| 253 | 295 | nextSummary, |
| ... | ... | @@ -264,7 +306,7 @@ export function reduceEvent( |
| 264 | 306 | }; |
| 265 | 307 | }); |
| 266 | 308 | case "thread.reverted": |
| 267 | return decodeThreadRevertedPayload(event.payload).pipe( | |
| 309 | return decodeForEvent(ThreadRevertedPayloadSchema, event.payload, event.type, "payload").pipe( | |
| 268 | 310 | Effect.map((payload) => { |
| 269 | 311 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); |
| 270 | 312 | if (!thread) { |
| ... | ... | @@ -311,7 +353,12 @@ export function reduceEvent( |
| 311 | 353 | }), |
| 312 | 354 | ); |
| 313 | 355 | case "git.read-model-upsert": |
| 314 | return decodeGitReadModel(event.payload).pipe( | |
| 356 | return decodeForEvent( | |
| 357 | OrchestrationGitReadModelSchema, | |
| 358 | event.payload, | |
| 359 | event.type, | |
| 360 | "payload", | |
| 361 | ).pipe( | |
| 315 | 362 | Effect.map((gitReadModel: OrchestrationGitReadModel) => ({ |
| 316 | 363 | ...nextBase, |
| 317 | 364 | gitByProjectId: { |
apps/server/src/persistence/Errors.ts created+126| ... | ... | @@ -0,0 +1,126 @@ |
| 1 | import { ParseResult, Schema } from "effect"; | |
| 2 | ||
| 3 | export class PersistenceSqlError extends Schema.TaggedError<PersistenceSqlError>()( | |
| 4 | "PersistenceSqlError", | |
| 5 | { | |
| 6 | operation: Schema.String, | |
| 7 | detail: Schema.String, | |
| 8 | cause: Schema.optional(Schema.Defect), | |
| 9 | }, | |
| 10 | ) { | |
| 11 | override get message(): string { | |
| 12 | return `SQL error in ${this.operation}: ${this.detail}`; | |
| 13 | } | |
| 14 | } | |
| 15 | ||
| 16 | export class PersistenceDecodeError extends Schema.TaggedError<PersistenceDecodeError>()( | |
| 17 | "PersistenceDecodeError", | |
| 18 | { | |
| 19 | operation: Schema.String, | |
| 20 | issue: Schema.String, | |
| 21 | cause: Schema.optional(Schema.Defect), | |
| 22 | }, | |
| 23 | ) { | |
| 24 | override get message(): string { | |
| 25 | return `Decode error in ${this.operation}: ${this.issue}`; | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | export class PersistenceSerializationError extends Schema.TaggedError<PersistenceSerializationError>()( | |
| 30 | "PersistenceSerializationError", | |
| 31 | { | |
| 32 | operation: Schema.String, | |
| 33 | detail: Schema.String, | |
| 34 | cause: Schema.optional(Schema.Defect), | |
| 35 | }, | |
| 36 | ) { | |
| 37 | override get message(): string { | |
| 38 | return `Serialization error in ${this.operation}: ${this.detail}`; | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | export class ProjectValidationError extends Schema.TaggedError<ProjectValidationError>()( | |
| 43 | "ProjectValidationError", | |
| 44 | { | |
| 45 | operation: Schema.String, | |
| 46 | issue: Schema.String, | |
| 47 | cause: Schema.optional(Schema.Defect), | |
| 48 | }, | |
| 49 | ) { | |
| 50 | override get message(): string { | |
| 51 | return `Project validation failed in ${this.operation}: ${this.issue}`; | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | export class ProjectPathMissingError extends Schema.TaggedError<ProjectPathMissingError>()( | |
| 56 | "ProjectPathMissingError", | |
| 57 | { | |
| 58 | cwd: Schema.String, | |
| 59 | cause: Schema.optional(Schema.Defect), | |
| 60 | }, | |
| 61 | ) { | |
| 62 | override get message(): string { | |
| 63 | return `Project path does not exist: ${this.cwd}`; | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | export class ProjectNotFoundError extends Schema.TaggedError<ProjectNotFoundError>()( | |
| 68 | "ProjectNotFoundError", | |
| 69 | { | |
| 70 | projectId: Schema.String, | |
| 71 | cause: Schema.optional(Schema.Defect), | |
| 72 | }, | |
| 73 | ) { | |
| 74 | override get message(): string { | |
| 75 | return `Project not found: ${this.projectId}`; | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | export type ProjectRepositoryError = | |
| 80 | | PersistenceSqlError | |
| 81 | | PersistenceDecodeError | |
| 82 | | PersistenceSerializationError | |
| 83 | | ProjectValidationError | |
| 84 | | ProjectPathMissingError | |
| 85 | | ProjectNotFoundError; | |
| 86 | ||
| 87 | export type OrchestrationEventRepositoryError = | |
| 88 | | PersistenceSqlError | |
| 89 | | PersistenceDecodeError | |
| 90 | | PersistenceSerializationError; | |
| 91 | ||
| 92 | export function toPersistenceSqlError(operation: string) { | |
| 93 | return (cause: unknown): PersistenceSqlError => | |
| 94 | new PersistenceSqlError({ | |
| 95 | operation, | |
| 96 | detail: `Failed to execute ${operation}`, | |
| 97 | cause, | |
| 98 | }); | |
| 99 | } | |
| 100 | ||
| 101 | export function toPersistenceDecodeError(operation: string) { | |
| 102 | return (error: ParseResult.ParseError): PersistenceDecodeError => | |
| 103 | new PersistenceDecodeError({ | |
| 104 | operation, | |
| 105 | issue: ParseResult.TreeFormatter.formatErrorSync(error), | |
| 106 | cause: error, | |
| 107 | }); | |
| 108 | } | |
| 109 | ||
| 110 | export function toPersistenceDecodeCauseError(operation: string) { | |
| 111 | return (cause: unknown): PersistenceDecodeError => | |
| 112 | new PersistenceDecodeError({ | |
| 113 | operation, | |
| 114 | issue: `Failed to execute ${operation}`, | |
| 115 | cause, | |
| 116 | }); | |
| 117 | } | |
| 118 | ||
| 119 | export function toPersistenceSerializationError(operation: string) { | |
| 120 | return (cause: unknown): PersistenceSerializationError => | |
| 121 | new PersistenceSerializationError({ | |
| 122 | operation, | |
| 123 | detail: `Failed to execute ${operation}`, | |
| 124 | cause, | |
| 125 | }); | |
| 126 | } |
apps/server/src/persistence/Layers/OrchestrationEvents.test.ts+2-3| ... | ... | @@ -80,9 +80,8 @@ describe("OrchestrationEventRepository", () => { |
| 80 | 80 | ) |
| 81 | 81 | .get() as { name: string } | undefined; |
| 82 | 82 | expect(tableRow?.name).toBeDefined(); |
| 83 | if (!tableRow) { | |
| 84 | throw new Error("Expected migrations table to exist"); | |
| 85 | } | |
| 83 | expect(tableRow).toBeDefined(); | |
| 84 | if (!tableRow) return; | |
| 86 | 85 | |
| 87 | 86 | const migrationRows = db |
| 88 | 87 | .prepare( |
apps/server/src/persistence/Layers/OrchestrationEvents.ts+59-23| ... | ... | @@ -4,13 +4,20 @@ import * as SqlClient from "@effect/sql/SqlClient"; |
| 4 | 4 | import * as SqlSchema from "@effect/sql/SqlSchema"; |
| 5 | 5 | import { Effect, Layer, Schema } from "effect"; |
| 6 | 6 | |
| 7 | import { | |
| 8 | toPersistenceDecodeCauseError, | |
| 9 | toPersistenceDecodeError, | |
| 10 | toPersistenceSerializationError, | |
| 11 | toPersistenceSqlError, | |
| 12 | type OrchestrationEventRepositoryError, | |
| 13 | } from "../Errors"; | |
| 7 | 14 | import { makeSqlitePersistenceLive } from "./Sqlite"; |
| 8 | 15 | import { |
| 9 | 16 | OrchestrationEventRepository, |
| 10 | 17 | type OrchestrationEventRepositoryShape, |
| 11 | 18 | } from "../Services/OrchestrationEvents"; |
| 12 | 19 | |
| 13 | const decodeEvent = Schema.decodeUnknownSync(OrchestrationEventSchema); | |
| 20 | const decodeEvent = Schema.decodeUnknown(OrchestrationEventSchema); | |
| 14 | 21 | |
| 15 | 22 | const EventRowSchema = Schema.Struct({ |
| 16 | 23 | sequence: Schema.Number, |
| ... | ... | @@ -40,17 +47,27 @@ const ReadFromSequenceRequestSchema = Schema.Struct({ |
| 40 | 47 | limit: Schema.Number, |
| 41 | 48 | }); |
| 42 | 49 | |
| 43 | function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { | |
| 44 | return decodeEvent({ | |
| 45 | sequence: row.sequence, | |
| 46 | eventId: row.eventId, | |
| 47 | type: row.type, | |
| 48 | aggregateType: row.aggregateType, | |
| 49 | aggregateId: row.aggregateId, | |
| 50 | occurredAt: row.occurredAt, | |
| 51 | commandId: row.commandId, | |
| 52 | payload: JSON.parse(row.payloadJson) as unknown, | |
| 53 | }); | |
| 50 | function eventRowToOrchestrationEvent( | |
| 51 | row: EventRow, | |
| 52 | operation: string, | |
| 53 | ): Effect.Effect<OrchestrationEvent, OrchestrationEventRepositoryError> { | |
| 54 | return Effect.try({ | |
| 55 | try: () => JSON.parse(row.payloadJson) as unknown, | |
| 56 | catch: toPersistenceDecodeCauseError(`${operation}:parsePayloadJson`), | |
| 57 | }).pipe( | |
| 58 | Effect.flatMap((payload) => | |
| 59 | decodeEvent({ | |
| 60 | sequence: row.sequence, | |
| 61 | eventId: row.eventId, | |
| 62 | type: row.type, | |
| 63 | aggregateType: row.aggregateType, | |
| 64 | aggregateId: row.aggregateId, | |
| 65 | occurredAt: row.occurredAt, | |
| 66 | commandId: row.commandId, | |
| 67 | payload, | |
| 68 | }).pipe(Effect.mapError(toPersistenceDecodeError(`${operation}:decodeEvent`))), | |
| 69 | ), | |
| 70 | ); | |
| 54 | 71 | } |
| 55 | 72 | |
| 56 | 73 | const makeRepository = Effect.gen(function* () { |
| ... | ... | @@ -113,23 +130,42 @@ const makeRepository = Effect.gen(function* () { |
| 113 | 130 | }); |
| 114 | 131 | |
| 115 | 132 | const append: OrchestrationEventRepositoryShape["append"] = (event) => |
| 116 | appendEventRow({ | |
| 117 | eventId: event.eventId, | |
| 118 | type: event.type, | |
| 119 | aggregateType: event.aggregateType, | |
| 120 | aggregateId: event.aggregateId, | |
| 121 | occurredAt: event.occurredAt, | |
| 122 | commandId: event.commandId, | |
| 123 | payloadJson: JSON.stringify(event.payload), | |
| 124 | }).pipe(Effect.map(eventRowToOrchestrationEvent), Effect.orDie); | |
| 133 | Effect.try({ | |
| 134 | try: () => JSON.stringify(event.payload), | |
| 135 | catch: toPersistenceSerializationError("OrchestrationEventRepository.append:encodePayload"), | |
| 136 | }).pipe( | |
| 137 | Effect.flatMap((payloadJson) => | |
| 138 | appendEventRow({ | |
| 139 | eventId: event.eventId, | |
| 140 | type: event.type, | |
| 141 | aggregateType: event.aggregateType, | |
| 142 | aggregateId: event.aggregateId, | |
| 143 | occurredAt: event.occurredAt, | |
| 144 | commandId: event.commandId, | |
| 145 | payloadJson, | |
| 146 | }).pipe( | |
| 147 | Effect.mapError(toPersistenceSqlError("OrchestrationEventRepository.append:insert")), | |
| 148 | Effect.flatMap((row) => | |
| 149 | eventRowToOrchestrationEvent(row, "OrchestrationEventRepository.append:rowToEvent"), | |
| 150 | ), | |
| 151 | ), | |
| 152 | ), | |
| 153 | ); | |
| 125 | 154 | |
| 126 | 155 | const readFromSequence: OrchestrationEventRepositoryShape["readFromSequence"] = ( |
| 127 | 156 | sequenceExclusive, |
| 128 | 157 | limit = 1_000, |
| 129 | 158 | ) => |
| 130 | 159 | readEventRowsFromSequence({ sequenceExclusive, limit }).pipe( |
| 131 | Effect.map((rows) => rows.map(eventRowToOrchestrationEvent)), | |
| 132 | Effect.orDie, | |
| 160 | Effect.mapError(toPersistenceSqlError("OrchestrationEventRepository.readFromSequence:query")), | |
| 161 | Effect.flatMap((rows) => | |
| 162 | Effect.forEach(rows, (row) => | |
| 163 | eventRowToOrchestrationEvent( | |
| 164 | row, | |
| 165 | "OrchestrationEventRepository.readFromSequence:rowToEvent", | |
| 166 | ), | |
| 167 | ), | |
| 168 | ), | |
| 133 | 169 | ); |
| 134 | 170 | |
| 135 | 171 | return { |
apps/server/src/persistence/Layers/Projects.ts+154-37| ... | ... | @@ -8,6 +8,7 @@ import type { |
| 8 | 8 | ProjectListResult, |
| 9 | 9 | ProjectRecord, |
| 10 | 10 | ProjectScript, |
| 11 | ProjectUpdateScriptsInput, | |
| 11 | 12 | ProjectUpdateScriptsResult, |
| 12 | 13 | } from "@t3tools/contracts"; |
| 13 | 14 | import { |
| ... | ... | @@ -22,6 +23,16 @@ import * as SqlClient from "@effect/sql/SqlClient"; |
| 22 | 23 | import * as SqlSchema from "@effect/sql/SqlSchema"; |
| 23 | 24 | import { Effect, Layer, Option, Schema } from "effect"; |
| 24 | 25 | |
| 26 | import { | |
| 27 | PersistenceDecodeError, | |
| 28 | ProjectNotFoundError, | |
| 29 | ProjectPathMissingError, | |
| 30 | ProjectValidationError, | |
| 31 | toPersistenceDecodeCauseError, | |
| 32 | toPersistenceSerializationError, | |
| 33 | toPersistenceSqlError, | |
| 34 | type ProjectRepositoryError, | |
| 35 | } from "../Errors"; | |
| 25 | 36 | import { ProjectRepository, type ProjectRepositoryShape } from "../Services/Projects"; |
| 26 | 37 | |
| 27 | 38 | const ProjectRowSchema = Schema.Struct({ |
| ... | ... | @@ -98,25 +109,101 @@ function inferProjectName(cwd: string): string { |
| 98 | 109 | return name.length > 0 ? name : "project"; |
| 99 | 110 | } |
| 100 | 111 | |
| 101 | function parseScriptsJson(raw: string): ProjectScript[] { | |
| 102 | try { | |
| 103 | const parsed = JSON.parse(raw) as unknown; | |
| 104 | const scripts = projectScriptsSchema.parse(parsed); | |
| 105 | return normalizeProjectScripts(scripts); | |
| 106 | } catch { | |
| 107 | return []; | |
| 112 | function parseProjectScripts( | |
| 113 | value: unknown, | |
| 114 | operation: string, | |
| 115 | ): Effect.Effect<ProjectScript[], PersistenceDecodeError> { | |
| 116 | const parsed = projectScriptsSchema.safeParse(value); | |
| 117 | if (!parsed.success) { | |
| 118 | return Effect.fail( | |
| 119 | new PersistenceDecodeError({ | |
| 120 | operation, | |
| 121 | issue: parsed.error.message, | |
| 122 | cause: (parsed.error as { cause?: unknown }).cause, | |
| 123 | }), | |
| 124 | ); | |
| 108 | 125 | } |
| 126 | return Effect.succeed(normalizeProjectScripts(parsed.data)); | |
| 109 | 127 | } |
| 110 | 128 | |
| 111 | function rowToProjectRecord(row: ProjectRow): ProjectRecord { | |
| 112 | return projectRecordSchema.parse({ | |
| 113 | id: row.id, | |
| 114 | cwd: row.cwd, | |
| 115 | name: row.name, | |
| 116 | scripts: parseScriptsJson(row.scriptsJson), | |
| 117 | createdAt: row.createdAt, | |
| 118 | updatedAt: row.updatedAt, | |
| 119 | }); | |
| 129 | function decodeScriptsJson( | |
| 130 | raw: string, | |
| 131 | operation: string, | |
| 132 | ): Effect.Effect<ProjectScript[], ProjectRepositoryError> { | |
| 133 | return Effect.try({ | |
| 134 | try: () => JSON.parse(raw) as unknown, | |
| 135 | catch: toPersistenceDecodeCauseError(`${operation}:parseScriptsJson`), | |
| 136 | }).pipe(Effect.flatMap((value) => parseProjectScripts(value, `${operation}:decodeScripts`))); | |
| 137 | } | |
| 138 | ||
| 139 | function rowToProjectRecord( | |
| 140 | row: ProjectRow, | |
| 141 | operation: string, | |
| 142 | ): Effect.Effect<ProjectRecord, ProjectRepositoryError> { | |
| 143 | return decodeScriptsJson(row.scriptsJson, `${operation}:scripts`).pipe( | |
| 144 | Effect.flatMap((scripts) => { | |
| 145 | const parsed = projectRecordSchema.safeParse({ | |
| 146 | id: row.id, | |
| 147 | cwd: row.cwd, | |
| 148 | name: row.name, | |
| 149 | scripts, | |
| 150 | createdAt: row.createdAt, | |
| 151 | updatedAt: row.updatedAt, | |
| 152 | }); | |
| 153 | if (!parsed.success) { | |
| 154 | return Effect.fail( | |
| 155 | new PersistenceDecodeError({ | |
| 156 | operation: `${operation}:projectRecord`, | |
| 157 | issue: parsed.error.message, | |
| 158 | cause: (parsed.error as { cause?: unknown }).cause, | |
| 159 | }), | |
| 160 | ); | |
| 161 | } | |
| 162 | return Effect.succeed(parsed.data); | |
| 163 | }), | |
| 164 | ); | |
| 165 | } | |
| 166 | ||
| 167 | function decodeAddInput(rawInput: ProjectAddInput) { | |
| 168 | const parsed = projectAddInputSchema.safeParse(rawInput); | |
| 169 | if (!parsed.success) { | |
| 170 | return Effect.fail( | |
| 171 | new ProjectValidationError({ | |
| 172 | operation: "ProjectRepository.add", | |
| 173 | issue: parsed.error.message, | |
| 174 | cause: (parsed.error as { cause?: unknown }).cause, | |
| 175 | }), | |
| 176 | ); | |
| 177 | } | |
| 178 | return Effect.succeed(parsed.data); | |
| 179 | } | |
| 180 | ||
| 181 | function decodeUpdateScriptsInput(rawInput: ProjectUpdateScriptsInput) { | |
| 182 | const parsed = projectUpdateScriptsInputSchema.safeParse(rawInput); | |
| 183 | if (!parsed.success) { | |
| 184 | return Effect.fail( | |
| 185 | new ProjectValidationError({ | |
| 186 | operation: "ProjectRepository.updateScripts", | |
| 187 | issue: parsed.error.message, | |
| 188 | cause: (parsed.error as { cause?: unknown }).cause, | |
| 189 | }), | |
| 190 | ); | |
| 191 | } | |
| 192 | return Effect.succeed(parsed.data); | |
| 193 | } | |
| 194 | ||
| 195 | function decodeRemoveInput(rawInput: unknown) { | |
| 196 | const parsed = projectRemoveInputSchema.safeParse(rawInput); | |
| 197 | if (!parsed.success) { | |
| 198 | return Effect.fail( | |
| 199 | new ProjectValidationError({ | |
| 200 | operation: "ProjectRepository.remove", | |
| 201 | issue: parsed.error.message, | |
| 202 | cause: (parsed.error as { cause?: unknown }).cause, | |
| 203 | }), | |
| 204 | ); | |
| 205 | } | |
| 206 | return Effect.succeed(parsed.data); | |
| 120 | 207 | } |
| 121 | 208 | |
| 122 | 209 | const makeRepository = Effect.gen(function* () { |
| ... | ... | @@ -231,24 +318,31 @@ const makeRepository = Effect.gen(function* () { |
| 231 | 318 | |
| 232 | 319 | const list: ProjectRepositoryShape["list"] = () => |
| 233 | 320 | listProjectRows(undefined).pipe( |
| 234 | Effect.map((rows): ProjectListResult => rows.map(rowToProjectRecord)), | |
| 235 | Effect.map((projects) => | |
| 236 | projects.map((project) => ({ ...project, scripts: cloneScripts(project.scripts) })), | |
| 321 | Effect.mapError(toPersistenceSqlError("ProjectRepository.list:query")), | |
| 322 | Effect.flatMap((rows) => | |
| 323 | Effect.forEach(rows, (row) => | |
| 324 | rowToProjectRecord(row, "ProjectRepository.list:rowToProject"), | |
| 325 | ), | |
| 326 | ), | |
| 327 | Effect.map( | |
| 328 | (projects): ProjectListResult => | |
| 329 | projects.map((project) => ({ ...project, scripts: cloneScripts(project.scripts) })), | |
| 237 | 330 | ), |
| 238 | Effect.orDie, | |
| 239 | 331 | ); |
| 240 | 332 | |
| 241 | 333 | const add: ProjectRepositoryShape["add"] = (rawInput) => |
| 242 | 334 | Effect.gen(function* () { |
| 243 | const input = projectAddInputSchema.parse(rawInput as ProjectAddInput); | |
| 335 | const input = yield* decodeAddInput(rawInput); | |
| 244 | 336 | const normalizedCwd = normalizeCwd(input.cwd); |
| 245 | 337 | if (!isDirectory(normalizedCwd)) { |
| 246 | return yield* Effect.fail(new Error(`Project path does not exist: ${normalizedCwd}`)); | |
| 338 | return yield* Effect.fail(new ProjectPathMissingError({ cwd: normalizedCwd })); | |
| 247 | 339 | } |
| 248 | 340 | |
| 249 | const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe(Effect.orDie); | |
| 341 | const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe( | |
| 342 | Effect.mapError(toPersistenceSqlError("ProjectRepository.add:findByCwd")), | |
| 343 | ); | |
| 250 | 344 | if (Option.isSome(existing)) { |
| 251 | const project = rowToProjectRecord(existing.value); | |
| 345 | const project = yield* rowToProjectRecord(existing.value, "ProjectRepository.add:existing"); | |
| 252 | 346 | const result: ProjectAddResult = { |
| 253 | 347 | project: { |
| 254 | 348 | ...project, |
| ... | ... | @@ -267,9 +361,9 @@ const makeRepository = Effect.gen(function* () { |
| 267 | 361 | scriptsJson: "[]", |
| 268 | 362 | createdAt: now, |
| 269 | 363 | updatedAt: now, |
| 270 | }).pipe(Effect.orDie); | |
| 364 | }).pipe(Effect.mapError(toPersistenceSqlError("ProjectRepository.add:insert"))); | |
| 271 | 365 | |
| 272 | const created = rowToProjectRecord(row); | |
| 366 | const created = yield* rowToProjectRecord(row, "ProjectRepository.add:created"); | |
| 273 | 367 | const result: ProjectAddResult = { |
| 274 | 368 | project: { |
| 275 | 369 | ...created, |
| ... | ... | @@ -282,27 +376,48 @@ const makeRepository = Effect.gen(function* () { |
| 282 | 376 | |
| 283 | 377 | const remove: ProjectRepositoryShape["remove"] = (rawInput) => |
| 284 | 378 | Effect.gen(function* () { |
| 285 | const input = projectRemoveInputSchema.parse(rawInput); | |
| 286 | yield* deleteProject({ id: input.id }).pipe(Effect.orDie); | |
| 379 | const input = yield* decodeRemoveInput(rawInput); | |
| 380 | const existing = yield* findProjectById({ id: input.id }).pipe( | |
| 381 | Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:findById")), | |
| 382 | ); | |
| 383 | if (Option.isNone(existing)) { | |
| 384 | return yield* Effect.fail(new ProjectNotFoundError({ projectId: input.id })); | |
| 385 | } | |
| 386 | ||
| 387 | yield* deleteProject({ id: input.id }).pipe( | |
| 388 | Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:delete")), | |
| 389 | ); | |
| 287 | 390 | }); |
| 288 | 391 | |
| 289 | 392 | const updateScripts: ProjectRepositoryShape["updateScripts"] = (rawInput) => |
| 290 | 393 | Effect.gen(function* () { |
| 291 | const input = projectUpdateScriptsInputSchema.parse(rawInput); | |
| 292 | const existing = yield* findProjectById({ id: input.id }).pipe(Effect.orDie); | |
| 394 | const input = yield* decodeUpdateScriptsInput(rawInput); | |
| 395 | const existing = yield* findProjectById({ id: input.id }).pipe( | |
| 396 | Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:findById")), | |
| 397 | ); | |
| 293 | 398 | if (Option.isNone(existing)) { |
| 294 | return yield* Effect.fail(new Error(`Project not found: ${input.id}`)); | |
| 399 | return yield* Effect.fail(new ProjectNotFoundError({ projectId: input.id })); | |
| 295 | 400 | } |
| 296 | 401 | |
| 297 | const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts)); | |
| 402 | const nextScripts = yield* parseProjectScripts( | |
| 403 | input.scripts, | |
| 404 | "ProjectRepository.updateScripts:decodeScripts", | |
| 405 | ); | |
| 406 | const scriptsJson = yield* Effect.try({ | |
| 407 | try: () => JSON.stringify(nextScripts), | |
| 408 | catch: toPersistenceSerializationError("ProjectRepository.updateScripts:encodeScripts"), | |
| 409 | }); | |
| 298 | 410 | const nextUpdatedAt = new Date().toISOString(); |
| 299 | 411 | const updatedRow = yield* updateProjectScripts({ |
| 300 | 412 | id: input.id, |
| 301 | scriptsJson: JSON.stringify(nextScripts), | |
| 413 | scriptsJson, | |
| 302 | 414 | updatedAt: nextUpdatedAt, |
| 303 | }).pipe(Effect.orDie); | |
| 415 | }).pipe(Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:update"))); | |
| 304 | 416 | |
| 305 | const updated = rowToProjectRecord(updatedRow); | |
| 417 | const updated = yield* rowToProjectRecord( | |
| 418 | updatedRow, | |
| 419 | "ProjectRepository.updateScripts:updated", | |
| 420 | ); | |
| 306 | 421 | const result: ProjectUpdateScriptsResult = { |
| 307 | 422 | project: { |
| 308 | 423 | ...updated, |
| ... | ... | @@ -314,17 +429,19 @@ const makeRepository = Effect.gen(function* () { |
| 314 | 429 | |
| 315 | 430 | const pruneMissing: ProjectRepositoryShape["pruneMissing"] = () => |
| 316 | 431 | listProjectRows(undefined).pipe( |
| 432 | Effect.mapError(toPersistenceSqlError("ProjectRepository.pruneMissing:query")), | |
| 317 | 433 | Effect.flatMap((rows) => |
| 318 | 434 | Effect.forEach(rows, (row) => { |
| 319 | 435 | const normalizedCwd = normalizeCwd(row.cwd); |
| 320 | 436 | if (isDirectory(normalizedCwd)) { |
| 321 | 437 | return Effect.void; |
| 322 | 438 | } |
| 323 | return deleteProject({ id: row.id }).pipe(Effect.orDie); | |
| 439 | return deleteProject({ id: row.id }).pipe( | |
| 440 | Effect.mapError(toPersistenceSqlError("ProjectRepository.pruneMissing:delete")), | |
| 441 | ); | |
| 324 | 442 | }), |
| 325 | 443 | ), |
| 326 | 444 | Effect.asVoid, |
| 327 | Effect.orDie, | |
| 328 | 445 | ); |
| 329 | 446 | |
| 330 | 447 | return { |
apps/server/src/persistence/Services/OrchestrationEvents.ts+44-3| ... | ... | @@ -1,18 +1,59 @@ |
| 1 | /** | |
| 2 | * OrchestrationEventRepository - Repository interface for orchestration events. | |
| 3 | * | |
| 4 | * Uses Effect `Context.Tag` for dependency injection and exposes typed | |
| 5 | * persistence/decode errors for event append and replay operations. | |
| 6 | * | |
| 7 | * @module OrchestrationEventRepository | |
| 8 | */ | |
| 1 | 9 | import type { OrchestrationEvent } from "@t3tools/contracts"; |
| 2 | 10 | import { Context } from "effect"; |
| 3 | 11 | import type { Effect } from "effect"; |
| 4 | 12 | |
| 13 | import type { OrchestrationEventRepositoryError } from "../Errors"; | |
| 14 | ||
| 5 | 15 | export interface OrchestrationEventRepositoryShape { |
| 16 | /** | |
| 17 | * Persist a new orchestration event. | |
| 18 | * | |
| 19 | * @param event - Event payload without sequence (assigned by storage). | |
| 20 | * @returns Effect containing the stored event with assigned sequence. | |
| 21 | */ | |
| 6 | 22 | readonly append: ( |
| 7 | 23 | event: Omit<OrchestrationEvent, "sequence">, |
| 8 | ) => Effect.Effect<OrchestrationEvent>; | |
| 24 | ) => Effect.Effect<OrchestrationEvent, OrchestrationEventRepositoryError>; | |
| 25 | ||
| 26 | /** | |
| 27 | * Replay events after the provided sequence. | |
| 28 | * | |
| 29 | * @param sequenceExclusive - Sequence cursor (exclusive). | |
| 30 | * @param limit - Maximum number of events to return. | |
| 31 | * @returns Effect containing ordered events. | |
| 32 | */ | |
| 9 | 33 | readonly readFromSequence: ( |
| 10 | 34 | sequenceExclusive: number, |
| 11 | 35 | limit?: number, |
| 12 | ) => Effect.Effect<OrchestrationEvent[]>; | |
| 13 | readonly readAll: () => Effect.Effect<OrchestrationEvent[]>; | |
| 36 | ) => Effect.Effect<OrchestrationEvent[], OrchestrationEventRepositoryError>; | |
| 37 | ||
| 38 | /** | |
| 39 | * Read all events from the beginning of the stream. | |
| 40 | * | |
| 41 | * @returns Effect containing all stored events. | |
| 42 | */ | |
| 43 | readonly readAll: () => Effect.Effect<OrchestrationEvent[], OrchestrationEventRepositoryError>; | |
| 14 | 44 | } |
| 15 | 45 | |
| 46 | /** | |
| 47 | * OrchestrationEventRepository - Context tag for orchestration event persistence. | |
| 48 | * | |
| 49 | * @example | |
| 50 | * ```ts | |
| 51 | * const program = Effect.gen(function* () { | |
| 52 | * const events = yield* OrchestrationEventRepository | |
| 53 | * return yield* events.readAll() | |
| 54 | * }) | |
| 55 | * ``` | |
| 56 | */ | |
| 16 | 57 | export class OrchestrationEventRepository extends Context.Tag("orchestration/EventRepository")< |
| 17 | 58 | OrchestrationEventRepository, |
| 18 | 59 | OrchestrationEventRepositoryShape |
apps/server/src/persistence/Services/Projects.ts+58-5| ... | ... | @@ -1,3 +1,11 @@ |
| 1 | /** | |
| 2 | * ProjectRepository - Repository interface for project persistence. | |
| 3 | * | |
| 4 | * Uses Effect `Context.Tag` for dependency injection and returns typed | |
| 5 | * domain errors for all operations. | |
| 6 | * | |
| 7 | * @module ProjectRepository | |
| 8 | */ | |
| 1 | 9 | import type { |
| 2 | 10 | ProjectAddInput, |
| 3 | 11 | ProjectAddResult, |
| ... | ... | @@ -9,16 +17,61 @@ import type { |
| 9 | 17 | import { Context } from "effect"; |
| 10 | 18 | import type { Effect } from "effect"; |
| 11 | 19 | |
| 20 | import type { ProjectRepositoryError } from "../Errors"; | |
| 21 | ||
| 12 | 22 | export interface ProjectRepositoryShape { |
| 13 | readonly list: () => Effect.Effect<ProjectListResult>; | |
| 14 | readonly add: (input: ProjectAddInput) => Effect.Effect<ProjectAddResult, Error>; | |
| 15 | readonly remove: (input: ProjectRemoveInput) => Effect.Effect<void, Error>; | |
| 23 | /** | |
| 24 | * List all persisted projects. | |
| 25 | * | |
| 26 | * @returns Effect containing all project records. | |
| 27 | */ | |
| 28 | readonly list: () => Effect.Effect<ProjectListResult, ProjectRepositoryError>; | |
| 29 | ||
| 30 | /** | |
| 31 | * Add a project by cwd, or return the existing project when the cwd already exists. | |
| 32 | * | |
| 33 | * @param input - Project creation payload. | |
| 34 | * @returns Effect containing the project and whether it was newly created. | |
| 35 | */ | |
| 36 | readonly add: (input: ProjectAddInput) => Effect.Effect<ProjectAddResult, ProjectRepositoryError>; | |
| 37 | ||
| 38 | /** | |
| 39 | * Remove a persisted project. | |
| 40 | * | |
| 41 | * @param input - Project removal payload. | |
| 42 | * @returns Effect containing void. | |
| 43 | */ | |
| 44 | readonly remove: (input: ProjectRemoveInput) => Effect.Effect<void, ProjectRepositoryError>; | |
| 45 | ||
| 46 | /** | |
| 47 | * Update the scripts for an existing project. | |
| 48 | * | |
| 49 | * @param input - Script update payload. | |
| 50 | * @returns Effect containing the updated project. | |
| 51 | */ | |
| 16 | 52 | readonly updateScripts: ( |
| 17 | 53 | input: ProjectUpdateScriptsInput, |
| 18 | ) => Effect.Effect<ProjectUpdateScriptsResult, Error>; | |
| 19 | readonly pruneMissing: () => Effect.Effect<void>; | |
| 54 | ) => Effect.Effect<ProjectUpdateScriptsResult, ProjectRepositoryError>; | |
| 55 | ||
| 56 | /** | |
| 57 | * Remove projects whose cwd no longer exists on disk. | |
| 58 | * | |
| 59 | * @returns Effect containing void. | |
| 60 | */ | |
| 61 | readonly pruneMissing: () => Effect.Effect<void, ProjectRepositoryError>; | |
| 20 | 62 | } |
| 21 | 63 | |
| 64 | /** | |
| 65 | * ProjectRepository - Context tag for project persistence dependency injection. | |
| 66 | * | |
| 67 | * @example | |
| 68 | * ```ts | |
| 69 | * const program = Effect.gen(function* () { | |
| 70 | * const projects = yield* ProjectRepository | |
| 71 | * return yield* projects.list() | |
| 72 | * }) | |
| 73 | * ``` | |
| 74 | */ | |
| 22 | 75 | export class ProjectRepository extends Context.Tag("persistence/ProjectRepository")< |
| 23 | 76 | ProjectRepository, |
| 24 | 77 | ProjectRepositoryShape |