Use Match for config operations

This commit is contained in:
elpresidank 2026-06-02 10:00:56 -05:00
parent 66e1009671
commit 8d5edfae9a
3 changed files with 103 additions and 35 deletions

View file

@ -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.

View file

@ -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({

View file

@ -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<ConfigServiceError>
readonly pushConfig: () => Promise<void>;
readonly pushConfigEffect: Effect.Effect<void, ConfigServiceError>;
readonly persist: () => Promise<void>;
readonly persistEffect: Effect.Effect<void, never>;
readonly persistEffect: Effect.Effect<void>;
readonly loadFromDisk: () => Promise<void>;
readonly loadFromDiskEffect: Effect.Effect<void, never>;
readonly loadFromDiskEffect: Effect.Effect<void>;
}
const initialState = (): ConfigServiceState => ({
@ -340,7 +340,7 @@ const updateHandles = (
const persistStateEffect = (
persistPath: string | null,
state: ConfigServiceState,
): Effect.Effect<void, never> =>
): Effect.Effect<void> =>
Effect.gen(function* () {
if (persistPath === null) return;
const payload = {
@ -383,7 +383,7 @@ const pushConfigWithStateEffect = (
const readPersistedConfigEffect = (
persistPath: string,
): Effect.Effect<PersistedConfig | null, never> =>
): Effect.Effect<PersistedConfig | null> =>
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<ConfigRequest>) {
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<void, ConfigServiceError> =>
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),