authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-22 11:07:39-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log5e3678c0af5023d02d938931b9efbb75b7c53f7e
tree9ceec8c187276a0c3793fcd357f90394e30bb513
parent0e4f1485c87a234b33cad6c197625d414bfd070c

fmt


5 files changed, 102 insertions(+), 111 deletions(-)

apps/server/src/orchestration/Layer.ts+98-103
...@@ -162,130 +162,125 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEve...@@ -162,130 +162,125 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEve
162162
163const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommandSchema);163const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommandSchema);
164164
165const makeOrchestrationEngine = 165const makeOrchestrationEngine = Effect.gen(function* () {
166 Effect.gen(function* () {166 const logger = createLogger("orchestration");
167 const logger = createLogger("orchestration");167 const eventStore = yield* OrchestrationEventRepository;
168 const eventStore = yield* OrchestrationEventRepository;
169168
170 let readModel = createEmptyReadModel(new Date().toISOString());169 let readModel = createEmptyReadModel(new Date().toISOString());
171170
172 const commandQueue = yield* Queue.unbounded<CommandEnvelope>();171 const commandQueue = yield* Queue.unbounded<CommandEnvelope>();
173 const readModelPubSub = yield* PubSub.unbounded<OrchestrationReadModel>();172 const readModelPubSub = yield* PubSub.unbounded<OrchestrationReadModel>();
174 const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>();173 const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>();
175174
176 const readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>();175 const readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>();
177 const domainEventListeners = new Set<(event: OrchestrationEvent) => void>();176 const domainEventListeners = new Set<(event: OrchestrationEvent) => void>();
178177
179 const notifyReadModelListeners = (snapshot: OrchestrationReadModel) =>178 const notifyReadModelListeners = (snapshot: OrchestrationReadModel) =>
180 Effect.forEach(readModelListeners, (listener) =>179 Effect.forEach(readModelListeners, (listener) =>
181 Effect.try({180 Effect.try({
182 try: () => listener(snapshot),181 try: () => listener(snapshot),
183 catch: toListenerCallbackError("read-model"),182 catch: toListenerCallbackError("read-model"),
184 }),183 }),
185 ).pipe(Effect.asVoid);184 ).pipe(Effect.asVoid);
186185
187 const notifyDomainEventListeners = (event: OrchestrationEvent) =>186 const notifyDomainEventListeners = (event: OrchestrationEvent) =>
188 Effect.forEach(domainEventListeners, (listener) =>187 Effect.forEach(domainEventListeners, (listener) =>
189 Effect.try({188 Effect.try({
190 try: () => listener(event),189 try: () => listener(event),
191 catch: toListenerCallbackError("domain-event"),190 catch: toListenerCallbackError("domain-event"),
192 }),191 }),
193 ).pipe(Effect.asVoid);192 ).pipe(Effect.asVoid);
194193
195 const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> =>194 const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> =>
196 Effect.gen(function* () {195 Effect.gen(function* () {
197 const eventBase = mapCommandToEvent(envelope.command);196 const eventBase = mapCommandToEvent(envelope.command);
198 const savedEvent = yield* eventStore.append(eventBase);197 const savedEvent = yield* eventStore.append(eventBase);
199 readModel = yield* reduceEvent(readModel, savedEvent);198 readModel = yield* reduceEvent(readModel, savedEvent);
200199
201 const snapshot = readModel;200 const snapshot = readModel;
202 yield* Effect.all([201 yield* Effect.all([
203 PubSub.publish(eventPubSub, savedEvent),202 PubSub.publish(eventPubSub, savedEvent),
204 PubSub.publish(readModelPubSub, snapshot),203 PubSub.publish(readModelPubSub, snapshot),
205 ]);204 ]);
206205
207 yield* notifyDomainEventListeners(savedEvent);206 yield* notifyDomainEventListeners(savedEvent);
208 yield* notifyReadModelListeners(snapshot);207 yield* notifyReadModelListeners(snapshot);
209208
210 yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence });209 yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence });
211 }).pipe(210 }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid)));
212 Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid)),
213 );
214211
215 const bootstrapReadModel: Effect.Effect<void, OrchestrationDispatchError> = Effect.gen(212 const bootstrapReadModel: Effect.Effect<void, OrchestrationDispatchError> = Effect.gen(
216 function* () {213 function* () {
217 const existingEvents = yield* eventStore.readAll();214 const existingEvents = yield* eventStore.readAll();
218 for (const event of existingEvents) {215 for (const event of existingEvents) {
219 readModel = yield* reduceEvent(readModel, event);216 readModel = yield* reduceEvent(readModel, event);
220 }217 }
221 },218 },
222 );219 );
223220
224 yield* bootstrapReadModel;221 yield* bootstrapReadModel;
225222
226 const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));223 const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));
227 yield* Effect.forkScoped(worker);224 yield* Effect.forkScoped(worker);
228 logger.info("orchestration engine started", {225 logger.info("orchestration engine started", {
229 sequence: readModel.sequence,226 sequence: readModel.sequence,
230 });227 });
231228
232 const getSnapshot: OrchestrationEngineShape["getSnapshot"] = () =>229 const getSnapshot: OrchestrationEngineShape["getSnapshot"] = () =>
233 Effect.sync((): OrchestrationReadModel => readModel);230 Effect.sync((): OrchestrationReadModel => readModel);
234231
235 const replayEvents: OrchestrationEngineShape["replayEvents"] = (fromSequenceExclusive) =>232 const replayEvents: OrchestrationEngineShape["replayEvents"] = (fromSequenceExclusive) =>
236 eventStore.readFromSequence(fromSequenceExclusive);233 eventStore.readFromSequence(fromSequenceExclusive);
237234
238 const dispatch: OrchestrationEngineShape["dispatch"] = (command) =>235 const dispatch: OrchestrationEngineShape["dispatch"] = (command) =>
239 Effect.gen(function* () {236 Effect.gen(function* () {
240 const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>();237 const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>();
241 yield* Queue.offer(commandQueue, { command, result });238 yield* Queue.offer(commandQueue, { command, result });
242 return yield* Deferred.await(result);239 return yield* Deferred.await(result);
243 });240 });
244241
245 const dispatchUnknown: OrchestrationEngineShape["dispatchUnknown"] = (command) =>242 const dispatchUnknown: OrchestrationEngineShape["dispatchUnknown"] = (command) =>
246 Effect.gen(function* () {243 Effect.gen(function* () {
247 const payload =244 const payload =
248 typeof command === "string"245 typeof command === "string"
249 ? yield* Effect.try({246 ? yield* Effect.try({
250 try: () => JSON.parse(command) as unknown,247 try: () => JSON.parse(command) as unknown,
251 catch: toOrchestrationJsonParseError,248 catch: toOrchestrationJsonParseError,
252 })249 })
253 : command;250 : command;
254251
255 const decoded = yield* decodeUnknownCommand(payload).pipe(252 const decoded = yield* decodeUnknownCommand(payload).pipe(
256 Effect.mapError(toOrchestrationCommandDecodeError),253 Effect.mapError(toOrchestrationCommandDecodeError),
257 );254 );
258255
259 return yield* dispatch(decoded);256 return yield* dispatch(decoded);
260 });257 });
261258
262 const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) =>259 const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) =>
263 Effect.sync(() => {260 Effect.sync(() => {
264 readModelListeners.add(callback);261 readModelListeners.add(callback);
265 return () => {262 return () => {
266 readModelListeners.delete(callback);263 readModelListeners.delete(callback);
267 };264 };
268 });265 });
269266
270 const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = (267 const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = (callback) =>
271 callback,268 Effect.sync(() => {
272 ) =>269 domainEventListeners.add(callback);
273 Effect.sync(() => {270 return () => {
274 domainEventListeners.add(callback);271 domainEventListeners.delete(callback);
275 return () => {272 };
276 domainEventListeners.delete(callback);273 });
277 };
278 });
279274
280 return {275 return {
281 getSnapshot,276 getSnapshot,
282 replayEvents,277 replayEvents,
283 dispatchUnknown,278 dispatchUnknown,
284 dispatch,279 dispatch,
285 subscribeToReadModel,280 subscribeToReadModel,
286 subscribeToDomainEvents,281 subscribeToDomainEvents,
287 } satisfies OrchestrationEngineShape;282 } satisfies OrchestrationEngineShape;
288 });283});
289284
290export const OrchestrationEngineLive = Layer.effect(285export const OrchestrationEngineLive = Layer.effect(
291 OrchestrationEngineService,286 OrchestrationEngineService,
apps/server/src/orchestration/reducer.ts+1-1
...@@ -36,7 +36,7 @@ function decodeForEvent<A>(...@@ -36,7 +36,7 @@ function decodeForEvent<A>(
36): Effect.Effect<A, OrchestrationReducerDecodeError> {36): Effect.Effect<A, OrchestrationReducerDecodeError> {
37 return Schema.decodeUnknownEffect(schema)(value).pipe(37 return Schema.decodeUnknownEffect(schema)(value).pipe(
38 Effect.mapError(toReducerDecodeError(`${eventType}:${field}`)),38 Effect.mapError(toReducerDecodeError(`${eventType}:${field}`)),
39 ) as Effect.Effect<A, OrchestrationReducerDecodeError> 39 ) as Effect.Effect<A, OrchestrationReducerDecodeError>;
40}40}
4141
42const ThreadCreatedPayloadSchema = Schema.Struct({42const ThreadCreatedPayloadSchema = Schema.Struct({
apps/server/src/persistence/Layers/OrchestrationEvents.test.ts+1-2
...@@ -3,7 +3,7 @@ import os from "node:os";...@@ -3,7 +3,7 @@ import os from "node:os";
3import path from "node:path";3import path from "node:path";
4import { DatabaseSync } from "node:sqlite";4import { DatabaseSync } from "node:sqlite";
55
6import { ManagedRuntime, Effect } from "effect";6import { ManagedRuntime, Effect } from "effect";
7import { afterEach, describe, expect, it } from "vitest";7import { afterEach, describe, expect, it } from "vitest";
88
9import { OrchestrationEventRepository } from "../Services/OrchestrationEvents.ts";9import { OrchestrationEventRepository } from "../Services/OrchestrationEvents.ts";
...@@ -57,7 +57,6 @@ describe("OrchestrationEventRepository", () => {...@@ -57,7 +57,6 @@ describe("OrchestrationEventRepository", () => {
57 const stateDir = makeTempDir("t3code-event-store-migrations-");57 const stateDir = makeTempDir("t3code-event-store-migrations-");
58 const dbPath = path.join(stateDir, "orchestration.sqlite");58 const dbPath = path.join(stateDir, "orchestration.sqlite");
5959
60
61 const firstRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath));60 const firstRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath));
62 const first = await firstRuntime.runPromise(Effect.service(OrchestrationEventRepository));61 const first = await firstRuntime.runPromise(Effect.service(OrchestrationEventRepository));
63 await firstRuntime.runPromise(first.readAll());62 await firstRuntime.runPromise(first.readAll());
apps/server/src/persistence/Layers/Projects.ts+2-2
...@@ -381,7 +381,7 @@ const makeRepository = Effect.gen(function* () {...@@ -381,7 +381,7 @@ const makeRepository = Effect.gen(function* () {
381 Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:findById")),381 Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:findById")),
382 );382 );
383 if (Option.isNone(existing)) {383 if (Option.isNone(existing)) {
384 return yield* (new ProjectNotFoundError({ projectId: input.id }));384 return yield* new ProjectNotFoundError({ projectId: input.id });
385 }385 }
386386
387 yield* deleteProject({ id: input.id }).pipe(387 yield* deleteProject({ id: input.id }).pipe(
...@@ -396,7 +396,7 @@ const makeRepository = Effect.gen(function* () {...@@ -396,7 +396,7 @@ const makeRepository = Effect.gen(function* () {
396 Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:findById")),396 Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:findById")),
397 );397 );
398 if (Option.isNone(existing)) {398 if (Option.isNone(existing)) {
399 return yield* (new ProjectNotFoundError({ projectId: input.id }));399 return yield* new ProjectNotFoundError({ projectId: input.id });
400 }400 }
401401
402 const nextScripts = yield* parseProjectScripts(402 const nextScripts = yield* parseProjectScripts(
apps/server/src/persistence/Layers/Sqlite.ts-3
...@@ -5,8 +5,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";...@@ -5,8 +5,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
5import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient";5import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient";
6import { Effect, Layer, ServiceMap } from "effect";6import { Effect, Layer, ServiceMap } from "effect";
77
8
9
10import { runMigrations } from "../Migrations.ts";8import { runMigrations } from "../Migrations.ts";
119
12export interface PersistenceConfigShape {10export interface PersistenceConfigShape {
...@@ -38,7 +36,6 @@ export function makeSqlitePersistenceLive(dbPath: string) {...@@ -38,7 +36,6 @@ export function makeSqlitePersistenceLive(dbPath: string) {
38 ).pipe(Layer.provide(SqliteClientLive));36 ).pipe(Layer.provide(SqliteClientLive));
39}37}
4038
41
42export const SqlitePersistenceLive = Layer.unwrap(39export const SqlitePersistenceLive = Layer.unwrap(
43 Effect.map(Effect.service(PersistenceConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)),40 Effect.map(Effect.service(PersistenceConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)),
44);41);