From 8d5edfae9a7fb626a90544e627aafa1379ae765f Mon Sep 17 00:00:00 2001 From: elpresidank Date: Tue, 2 Jun 2026 10:00:56 -0500 Subject: [PATCH] Use Match for config operations --- ts/EFFECT_NATIVE_REWRITE_AUDIT.md | 23 ++++++- .../flow/src/__tests__/config-service.test.ts | 52 +++++++++++++++ ts/packages/flow/src/config/service.ts | 63 +++++++++---------- 3 files changed, 103 insertions(+), 35 deletions(-) diff --git a/ts/EFFECT_NATIVE_REWRITE_AUDIT.md b/ts/EFFECT_NATIVE_REWRITE_AUDIT.md index 14c3a60b..81515725 100644 --- a/ts/EFFECT_NATIVE_REWRITE_AUDIT.md +++ b/ts/EFFECT_NATIVE_REWRITE_AUDIT.md @@ -346,6 +346,25 @@ Notes: - `git diff --check` - `git diff --check` +### 2026-06-02: ConfigService Operation Match Slice + +- Status: migrated and package-verified. +- Completed: + - `ts/packages/flow/src/config/service.ts` now dispatches + `ConfigOperation` with `effect/Match` instead of a native `switch`. + - The dispatcher is a named `Effect.fn` and uses `Match.exhaustive` against + the schema-derived `ConfigOperation` union. + - The per-message response sender now uses `Effect.fnUntraced` instead of an + arrow function returning `Effect.gen(...)`. + - Config-service tests now cover all seven operations through + `handleOperation`, including tagged invalid mutation failures. + - Existing explicit `never` annotations on `persistEffect`, + `loadFromDiskEffect`, `persistStateEffect`, and + `readPersistedConfigEffect` were removed so Effect can infer the channel. +- Verification: + - `bun run --cwd ts/packages/flow test -- src/__tests__/config-service.test.ts` + - `cd ts && bun run check:tsgo` + ### 2026-06-02: RAG And Agent Requestor Bridge Slice - Status: migrated, root-verified, committed, and pushed. @@ -1792,8 +1811,8 @@ Notes: - FlowManager `() => Effect.gen(...)` factories are normalized to `Effect.fn` / `Effect.fnUntraced`. Sibling service factories still need a focused scan before treating them as valid migration targets. - - KnowledgeCore operation dispatch now uses `effect/Match` with - `Match.exhaustive`; remaining service operation switches are in config and + - ConfigService and KnowledgeCore operation dispatch now use `effect/Match` + with `Match.exhaustive`; remaining service operation switches are in librarian surfaces. - Long-lived `Map` / `Set` state in ref-backed services can move toward Effect collections later; local pure traversal maps/sets remain no-ops. diff --git a/ts/packages/flow/src/__tests__/config-service.test.ts b/ts/packages/flow/src/__tests__/config-service.test.ts index 5b5657ef..d089a113 100644 --- a/ts/packages/flow/src/__tests__/config-service.test.ts +++ b/ts/packages/flow/src/__tests__/config-service.test.ts @@ -147,6 +147,58 @@ describe("ConfigService operations", () => { ]); }); + it("dispatches all config operations through the Match-backed handler", async () => { + const service = makeService(); + + await expect(service.handleOperation({ operation: "put" })).rejects.toMatchObject({ + _tag: "ConfigServiceError", + operation: "put", + }); + await expect(service.handleOperation({ operation: "delete" })).rejects.toMatchObject({ + _tag: "ConfigServiceError", + operation: "delete", + }); + + await expect(service.handleOperation({ + operation: "put", + values: [{ type: "prompt", key: "system", value: "hello" }], + })).resolves.toEqual({ version: 1 }); + + await expect(service.handleOperation({ + operation: "get", + keys: ["prompt", "system"], + })).resolves.toEqual({ + version: 1, + values: { system: "hello" }, + }); + await expect(service.handleOperation({ operation: "list" })).resolves.toEqual({ + version: 1, + directory: ["prompt"], + }); + await expect(service.handleOperation({ operation: "config" })).resolves.toEqual({ + version: 1, + config: { prompt: { system: "hello" } }, + }); + await expect(service.handleOperation({ + operation: "getvalues", + type: "prompt", + })).resolves.toEqual({ + version: 1, + values: [{ type: "prompt", key: "system", value: "hello" }], + }); + await expect(service.handleOperation({ + operation: "getvalues-all-ws", + type: "prompt", + })).resolves.toEqual({ + version: 1, + values: [{ workspace: "default", type: "prompt", key: "system", value: "hello" }], + }); + await expect(service.handleOperation({ + operation: "delete", + keys: ["prompt", "system"], + })).resolves.toEqual({ version: 2 }); + }); + it("pushes config from the stored producer handle", async () => { const backend = new NoopPubSub(); const service = makeConfigService({ diff --git a/ts/packages/flow/src/config/service.ts b/ts/packages/flow/src/config/service.ts index 6e5c6f13..0b3ab818 100644 --- a/ts/packages/flow/src/config/service.ts +++ b/ts/packages/flow/src/config/service.ts @@ -5,7 +5,7 @@ */ import {NodeRuntime} from "@effect/platform-node"; -import {Duration, Effect, Layer, ManagedRuntime, SynchronizedRef} from "effect"; +import {Duration, Effect, Layer, ManagedRuntime, Match, SynchronizedRef} from "effect"; import * as Predicate from "effect/Predicate"; import * as S from "effect/Schema"; import { @@ -109,9 +109,9 @@ export interface ConfigService extends AsyncProcessorRuntime readonly pushConfig: () => Promise; readonly pushConfigEffect: Effect.Effect; readonly persist: () => Promise; - readonly persistEffect: Effect.Effect; + readonly persistEffect: Effect.Effect; readonly loadFromDisk: () => Promise; - readonly loadFromDiskEffect: Effect.Effect; + readonly loadFromDiskEffect: Effect.Effect; } const initialState = (): ConfigServiceState => ({ @@ -340,7 +340,7 @@ const updateHandles = ( const persistStateEffect = ( persistPath: string | null, state: ConfigServiceState, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { if (persistPath === null) return; const payload = { @@ -383,7 +383,7 @@ const pushConfigWithStateEffect = ( const readPersistedConfigEffect = ( persistPath: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const raw = yield* Effect.tryPromise({ try: () => readTextFile(persistPath), @@ -778,26 +778,24 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService { const baseStop = base.stop; const persistPath = config.persistPath ?? null; - const handleOperationEffect = (request: ConfigRequest) => { + const handleOperationEffect = Effect.fn("ConfigService.handleOperation")(function* ( + request: ConfigRequest, + ) { const op: ConfigOperation = request.operation; - switch (op) { - case "get": - return Effect.succeed(handleGetWithState(stateSnapshot(state), request)); - case "put": - return handlePutEffect(state, persistPath, request); - case "delete": - return handleDeleteEffect(state, persistPath, request); - case "list": - return Effect.succeed(handleListWithState(stateSnapshot(state), request)); - case "config": - return Effect.succeed(handleConfigDumpWithState(stateSnapshot(state), request)); - case "getvalues": - return Effect.succeed(handleGetValuesWithState(stateSnapshot(state), request)); - case "getvalues-all-ws": - return Effect.succeed(handleGetValuesAllWorkspacesWithState(stateSnapshot(state), request)); - } - }; + return yield* Match.value(op).pipe( + Match.when("get", () => Effect.succeed(handleGetWithState(stateSnapshot(state), request))), + Match.when("put", () => handlePutEffect(state, persistPath, request)), + Match.when("delete", () => handleDeleteEffect(state, persistPath, request)), + Match.when("list", () => Effect.succeed(handleListWithState(stateSnapshot(state), request))), + Match.when("config", () => Effect.succeed(handleConfigDumpWithState(stateSnapshot(state), request))), + Match.when("getvalues", () => Effect.succeed(handleGetValuesWithState(stateSnapshot(state), request))), + Match.when("getvalues-all-ws", () => + Effect.succeed(handleGetValuesAllWorkspacesWithState(stateSnapshot(state), request)) + ), + Match.exhaustive, + ); + }); const handleMessageEffect = Effect.fn("handleMessageEffect")(function* (msg: Message) { const request = yield* S.decodeUnknownEffect(ConfigRequestSchema)(msg.value()).pipe( @@ -810,17 +808,16 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService { return; } - const sendResponse = (response: ConfigResponse): Effect.Effect => - Effect.gen(function* () { - const responseProducer = (yield* SynchronizedRef.get(state)).responseProducer; - if (responseProducer === null) { - return yield* configServiceError("respond", "Config response producer not started"); - } - yield* Effect.tryPromise({ - try: () => responseProducer.send(response, {id: requestId}), - catch: (cause) => configServiceError("respond", cause), - }); + const sendResponse = Effect.fnUntraced(function* (response: ConfigResponse) { + const responseProducer = (yield* SynchronizedRef.get(state)).responseProducer; + if (responseProducer === null) { + return yield* configServiceError("respond", "Config response producer not started"); + } + yield* Effect.tryPromise({ + try: () => responseProducer.send(response, {id: requestId}), + catch: (cause) => configServiceError("respond", cause), }); + }); yield* handleOperationEffect(request).pipe( Effect.flatMap(sendResponse),