mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
Use Effect fn for config service helpers
This commit is contained in:
parent
3890a598b5
commit
6ba4cf3b32
2 changed files with 193 additions and 177 deletions
|
|
@ -1942,6 +1942,27 @@ Notes:
|
||||||
- `cd ts && bun run lint`
|
- `cd ts && bun run lint`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
|
|
||||||
|
### 2026-06-04: Config Service Effect.fn Helper Slice
|
||||||
|
|
||||||
|
- Status: migrated and package-verified.
|
||||||
|
- Completed:
|
||||||
|
- `ts/packages/flow/src/config/service.ts` now defines its reusable
|
||||||
|
generator helpers with `Effect.fn` or `Effect.fnUntraced` instead of
|
||||||
|
arrow functions returning `Effect.gen`.
|
||||||
|
- `consumeOnceEffect` uses `Effect.fnUntraced`; persistence, push, read,
|
||||||
|
put/delete, close, and run helpers use named `Effect.fn` wrappers.
|
||||||
|
- Existing persistence read/write catch/logging behavior is preserved through
|
||||||
|
`Effect.fn` post-processing functions.
|
||||||
|
- The focused scan for config-service helper `=> Effect.gen` patterns is
|
||||||
|
clean.
|
||||||
|
- Verification:
|
||||||
|
- `cd ts/packages/flow && bunx --bun vitest run src/__tests__/config-service.test.ts`
|
||||||
|
- `cd ts && bun run check:tsgo`
|
||||||
|
- `cd ts && bun run build`
|
||||||
|
- `cd ts && bun run test`
|
||||||
|
- `cd ts && bun run lint`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
## Subagent Findings To Preserve
|
## Subagent Findings To Preserve
|
||||||
|
|
||||||
- MCP/workbench:
|
- MCP/workbench:
|
||||||
|
|
@ -2067,9 +2088,9 @@ Notes:
|
||||||
- Gateway dispatcher static service registries, streaming membership, and
|
- Gateway dispatcher static service registries, streaming membership, and
|
||||||
scoped requestor cache now use Effect `HashMap`/`HashSet`; gateway
|
scoped requestor cache now use Effect `HashMap`/`HashSet`; gateway
|
||||||
term-bearing service membership sets now use Effect `HashSet` too.
|
term-bearing service membership sets now use Effect `HashSet` too.
|
||||||
- FlowManager and KnowledgeCore `() => Effect.gen(...)` factories are
|
- FlowManager, KnowledgeCore, and ConfigService `() => Effect.gen(...)`
|
||||||
normalized to `Effect.fn` / `Effect.fnUntraced`. Config and Librarian
|
factories are normalized to `Effect.fn` / `Effect.fnUntraced`. Librarian
|
||||||
helper factories still need focused follow-up slices.
|
helper factories still need a focused follow-up slice.
|
||||||
- ConfigService and KnowledgeCore operation dispatch now use `effect/Match`
|
- ConfigService and KnowledgeCore operation dispatch now use `effect/Match`
|
||||||
with `Match.exhaustive`; FlowManager and Librarian operation dispatch now
|
with `Match.exhaustive`; FlowManager and Librarian operation dispatch now
|
||||||
use `effect/Match` with runtime-preserving `Match.orElse` fallbacks.
|
use `effect/Match` with runtime-preserving `Match.orElse` fallbacks.
|
||||||
|
|
|
||||||
|
|
@ -313,11 +313,8 @@ const updateHandles = (
|
||||||
pushProducer: handles.pushProducer === undefined ? state.pushProducer : handles.pushProducer,
|
pushProducer: handles.pushProducer === undefined ? state.pushProducer : handles.pushProducer,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const persistStateEffect = (
|
const persistStateEffect = Effect.fn("ConfigService.persistState")(
|
||||||
persistPath: string | null,
|
function* (persistPath: string | null, state: ConfigServiceState) {
|
||||||
state: ConfigServiceState,
|
|
||||||
): Effect.Effect<void> =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (persistPath === null) return;
|
if (persistPath === null) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
version: state.version,
|
version: state.version,
|
||||||
|
|
@ -332,35 +329,35 @@ const persistStateEffect = (
|
||||||
try: () => writeTextFile(persistPath, json),
|
try: () => writeTextFile(persistPath, json),
|
||||||
catch: (cause) => configServiceError("persist-write", cause),
|
catch: (cause) => configServiceError("persist-write", cause),
|
||||||
});
|
});
|
||||||
}).pipe(
|
},
|
||||||
Effect.catch((err) =>
|
(effect) =>
|
||||||
Effect.logError("[ConfigService] Failed to persist config", {error: err.message}),
|
effect.pipe(
|
||||||
|
Effect.catch((err) =>
|
||||||
|
Effect.logError("[ConfigService] Failed to persist config", {error: err.message}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const pushConfigWithStateEffect = (
|
const pushConfigWithStateEffect = Effect.fn("ConfigService.pushConfigWithState")(function* (
|
||||||
state: ConfigServiceState,
|
state: ConfigServiceState,
|
||||||
): Effect.Effect<void, ConfigServiceError> =>
|
) {
|
||||||
Effect.gen(function* () {
|
const pushProducer = state.pushProducer;
|
||||||
const pushProducer = state.pushProducer;
|
if (pushProducer === null) return;
|
||||||
if (pushProducer === null) return;
|
|
||||||
|
|
||||||
yield* Effect.tryPromise({
|
yield* Effect.tryPromise({
|
||||||
try: () =>
|
try: () =>
|
||||||
pushProducer.send({
|
pushProducer.send({
|
||||||
version: state.version,
|
version: state.version,
|
||||||
config: configDumpForState(state),
|
config: configDumpForState(state),
|
||||||
}),
|
}),
|
||||||
catch: (cause) => configServiceError("push-config", cause),
|
catch: (cause) => configServiceError("push-config", cause),
|
||||||
});
|
|
||||||
|
|
||||||
yield* Effect.log(`[ConfigService] Pushed configuration version ${state.version}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const readPersistedConfigEffect = (
|
yield* Effect.log(`[ConfigService] Pushed configuration version ${state.version}`);
|
||||||
persistPath: string,
|
});
|
||||||
): Effect.Effect<PersistedConfig | null> =>
|
|
||||||
Effect.gen(function* () {
|
const readPersistedConfigEffect = Effect.fn("ConfigService.readPersistedConfig")(
|
||||||
|
function* (persistPath: string) {
|
||||||
const raw = yield* Effect.tryPromise({
|
const raw = yield* Effect.tryPromise({
|
||||||
try: () => readTextFile(persistPath),
|
try: () => readTextFile(persistPath),
|
||||||
catch: (cause) => configServiceError("persist-read", cause),
|
catch: (cause) => configServiceError("persist-read", cause),
|
||||||
|
|
@ -368,11 +365,14 @@ const readPersistedConfigEffect = (
|
||||||
return yield* S.decodeUnknownEffect(PersistedConfigJsonSchema)(raw).pipe(
|
return yield* S.decodeUnknownEffect(PersistedConfigJsonSchema)(raw).pipe(
|
||||||
Effect.mapError((cause) => configServiceError("persist-decode", cause)),
|
Effect.mapError((cause) => configServiceError("persist-decode", cause)),
|
||||||
);
|
);
|
||||||
}).pipe(
|
},
|
||||||
Effect.catch(() =>
|
(effect) =>
|
||||||
Effect.log("[ConfigService] No persisted config found, starting fresh").pipe(
|
effect.pipe(
|
||||||
Effect.flatMap(() => Effect.succeed<PersistedConfig | null>(null)),
|
Effect.catch(() =>
|
||||||
)
|
Effect.log("[ConfigService] No persisted config found, starting fresh").pipe(
|
||||||
|
Effect.flatMap(() => Effect.succeed<PersistedConfig | null>(null)),
|
||||||
|
)
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -515,59 +515,57 @@ const applyDeleteStringKeys = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePutEffect = (
|
const handlePutEffect = Effect.fn("ConfigService.handlePut")(function* (
|
||||||
stateRef: SynchronizedRef.SynchronizedRef<ConfigServiceState>,
|
stateRef: SynchronizedRef.SynchronizedRef<ConfigServiceState>,
|
||||||
persistPath: string | null,
|
persistPath: string | null,
|
||||||
request: ConfigRequest,
|
request: ConfigRequest,
|
||||||
): Effect.Effect<ConfigResponse, ConfigServiceError> =>
|
) {
|
||||||
Effect.gen(function* () {
|
const values = configValues(request);
|
||||||
const values = configValues(request);
|
if (values.length === 0) return yield* configServiceError("put", "Put requires config values");
|
||||||
if (values.length === 0) return yield* configServiceError("put", "Put requires config values");
|
|
||||||
|
|
||||||
const next = yield* SynchronizedRef.updateAndGet(stateRef, (state) => applyPut(state, values));
|
const next = yield* SynchronizedRef.updateAndGet(stateRef, (state) => applyPut(state, values));
|
||||||
yield* persistStateEffect(persistPath, next);
|
yield* persistStateEffect(persistPath, next);
|
||||||
yield* pushConfigWithStateEffect(next);
|
yield* pushConfigWithStateEffect(next);
|
||||||
|
|
||||||
return {version: next.version};
|
return {version: next.version};
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDeleteEffect = (
|
const handleDeleteEffect = Effect.fn("ConfigService.handleDelete")(function* (
|
||||||
stateRef: SynchronizedRef.SynchronizedRef<ConfigServiceState>,
|
stateRef: SynchronizedRef.SynchronizedRef<ConfigServiceState>,
|
||||||
persistPath: string | null,
|
persistPath: string | null,
|
||||||
request: ConfigRequest,
|
request: ConfigRequest,
|
||||||
): Effect.Effect<ConfigResponse, ConfigServiceError> =>
|
) {
|
||||||
Effect.gen(function* () {
|
const workspace = workspaceFor(request);
|
||||||
const workspace = workspaceFor(request);
|
const keysByObject = objectKeys(request);
|
||||||
const keysByObject = objectKeys(request);
|
|
||||||
|
|
||||||
if (keysByObject.length > 0) {
|
|
||||||
const next = yield* SynchronizedRef.updateAndGet(
|
|
||||||
stateRef,
|
|
||||||
(state) => applyDeleteObjectKeys(state, workspace, keysByObject),
|
|
||||||
);
|
|
||||||
yield* persistStateEffect(persistPath, next);
|
|
||||||
yield* pushConfigWithStateEffect(next);
|
|
||||||
return {version: next.version};
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = stringKeys(request);
|
|
||||||
if (keys.length === 0) {
|
|
||||||
return yield* configServiceError("delete", "Delete requires at least one key");
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = yield* SynchronizedRef.get(stateRef);
|
|
||||||
if (getWorkspaceStore(previous, workspace) === undefined) {
|
|
||||||
return {version: previous.version};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (keysByObject.length > 0) {
|
||||||
const next = yield* SynchronizedRef.updateAndGet(
|
const next = yield* SynchronizedRef.updateAndGet(
|
||||||
stateRef,
|
stateRef,
|
||||||
(state) => applyDeleteStringKeys(state, workspace, keys),
|
(state) => applyDeleteObjectKeys(state, workspace, keysByObject),
|
||||||
);
|
);
|
||||||
yield* persistStateEffect(persistPath, next);
|
yield* persistStateEffect(persistPath, next);
|
||||||
yield* pushConfigWithStateEffect(next);
|
yield* pushConfigWithStateEffect(next);
|
||||||
return {version: next.version};
|
return {version: next.version};
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const keys = stringKeys(request);
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return yield* configServiceError("delete", "Delete requires at least one key");
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = yield* SynchronizedRef.get(stateRef);
|
||||||
|
if (getWorkspaceStore(previous, workspace) === undefined) {
|
||||||
|
return {version: previous.version};
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = yield* SynchronizedRef.updateAndGet(
|
||||||
|
stateRef,
|
||||||
|
(state) => applyDeleteStringKeys(state, workspace, keys),
|
||||||
|
);
|
||||||
|
yield* persistStateEffect(persistPath, next);
|
||||||
|
yield* pushConfigWithStateEffect(next);
|
||||||
|
return {version: next.version};
|
||||||
|
});
|
||||||
|
|
||||||
const handleListWithState = (
|
const handleListWithState = (
|
||||||
state: ConfigServiceState,
|
state: ConfigServiceState,
|
||||||
|
|
@ -639,118 +637,115 @@ const handleConfigDumpWithState = (
|
||||||
config: configDumpForState(state, workspaceFor(request)),
|
config: configDumpForState(state, workspaceFor(request)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeConfigResourcesEffect = (
|
const closeConfigResourcesEffect = Effect.fn("ConfigService.closeResources")(function* (
|
||||||
stateRef: SynchronizedRef.SynchronizedRef<ConfigServiceState>,
|
stateRef: SynchronizedRef.SynchronizedRef<ConfigServiceState>,
|
||||||
): Effect.Effect<void, ConfigServiceError> =>
|
) {
|
||||||
Effect.gen(function* () {
|
const state = yield* SynchronizedRef.get(stateRef);
|
||||||
const state = yield* SynchronizedRef.get(stateRef);
|
|
||||||
|
|
||||||
const consumer = state.consumer;
|
const consumer = state.consumer;
|
||||||
if (consumer !== null) {
|
if (consumer !== null) {
|
||||||
yield* Effect.tryPromise({
|
|
||||||
try: () => consumer.close(),
|
|
||||||
catch: (cause) => configServiceError("close-consumer", cause),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const responseProducer = state.responseProducer;
|
|
||||||
if (responseProducer !== null) {
|
|
||||||
yield* Effect.tryPromise({
|
|
||||||
try: () => responseProducer.close(),
|
|
||||||
catch: (cause) => configServiceError("close-response-producer", cause),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const pushProducer = state.pushProducer;
|
|
||||||
if (pushProducer !== null) {
|
|
||||||
yield* Effect.tryPromise({
|
|
||||||
try: () => pushProducer.close(),
|
|
||||||
catch: (cause) => configServiceError("close-push-producer", cause),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* updateHandles(stateRef, {
|
|
||||||
consumer: null,
|
|
||||||
responseProducer: null,
|
|
||||||
pushProducer: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const consumeOnceEffect = (
|
|
||||||
service: ConfigService,
|
|
||||||
): Effect.Effect<void, ConfigServiceError> =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = yield* SynchronizedRef.get(service.state);
|
|
||||||
const consumer = state.consumer;
|
|
||||||
if (consumer === null) {
|
|
||||||
return yield* configServiceError("consume", "Config consumer not started");
|
|
||||||
}
|
|
||||||
|
|
||||||
const msg = yield* Effect.tryPromise({
|
|
||||||
try: () => consumer.receive(2000),
|
|
||||||
catch: (cause) => configServiceError("consume-receive", cause),
|
|
||||||
});
|
|
||||||
if (msg === null) return;
|
|
||||||
|
|
||||||
yield* service.handleMessageEffect(msg);
|
|
||||||
yield* Effect.tryPromise({
|
yield* Effect.tryPromise({
|
||||||
try: () => consumer.acknowledge(msg),
|
try: () => consumer.close(),
|
||||||
catch: (cause) => configServiceError("consume-acknowledge", cause),
|
catch: (cause) => configServiceError("close-consumer", cause),
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
const responseProducer = state.responseProducer;
|
||||||
|
if (responseProducer !== null) {
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: () => responseProducer.close(),
|
||||||
|
catch: (cause) => configServiceError("close-response-producer", cause),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pushProducer = state.pushProducer;
|
||||||
|
if (pushProducer !== null) {
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: () => pushProducer.close(),
|
||||||
|
catch: (cause) => configServiceError("close-push-producer", cause),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const runConfigServiceEffect = (
|
yield* updateHandles(stateRef, {
|
||||||
|
consumer: null,
|
||||||
|
responseProducer: null,
|
||||||
|
pushProducer: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const consumeOnceEffect = Effect.fnUntraced(function* (
|
||||||
service: ConfigService,
|
service: ConfigService,
|
||||||
): Effect.Effect<void, ConfigServiceError> =>
|
) {
|
||||||
Effect.gen(function* () {
|
const state = yield* SynchronizedRef.get(service.state);
|
||||||
yield* service.loadFromDiskEffect;
|
const consumer = state.consumer;
|
||||||
|
if (consumer === null) {
|
||||||
|
return yield* configServiceError("consume", "Config consumer not started");
|
||||||
|
}
|
||||||
|
|
||||||
const responseProducer = yield* Effect.tryPromise({
|
const msg = yield* Effect.tryPromise({
|
||||||
try: () =>
|
try: () => consumer.receive(2000),
|
||||||
service.pubsub.createProducer<ConfigResponse>({
|
catch: (cause) => configServiceError("consume-receive", cause),
|
||||||
topic: topics.configResponse,
|
|
||||||
schema: ConfigResponseSchema,
|
|
||||||
}),
|
|
||||||
catch: (cause) => configServiceError("response-producer", cause),
|
|
||||||
});
|
|
||||||
yield* updateHandles(service.state, {responseProducer});
|
|
||||||
|
|
||||||
const pushProducer = yield* Effect.tryPromise({
|
|
||||||
try: () =>
|
|
||||||
service.pubsub.createProducer<ConfigPush>({
|
|
||||||
topic: topics.configPush,
|
|
||||||
schema: ConfigPushSchema,
|
|
||||||
}),
|
|
||||||
catch: (cause) => configServiceError("push-producer", cause),
|
|
||||||
});
|
|
||||||
yield* updateHandles(service.state, {pushProducer});
|
|
||||||
|
|
||||||
const consumer = yield* Effect.tryPromise({
|
|
||||||
try: () =>
|
|
||||||
service.pubsub.createConsumer<ConfigRequest>({
|
|
||||||
topic: topics.configRequest,
|
|
||||||
subscription: `${service.config.id}-config-request`,
|
|
||||||
schema: ConfigRequestSchema,
|
|
||||||
}),
|
|
||||||
catch: (cause) => configServiceError("consumer", cause),
|
|
||||||
});
|
|
||||||
const state = yield* updateHandles(service.state, {consumer});
|
|
||||||
|
|
||||||
yield* pushConfigWithStateEffect(state);
|
|
||||||
yield* Effect.log(`[ConfigService] Listening on ${topics.configRequest}`);
|
|
||||||
|
|
||||||
yield* Effect.whileLoop({
|
|
||||||
while: () => service.running,
|
|
||||||
body: () =>
|
|
||||||
consumeOnceEffect(service).pipe(
|
|
||||||
Effect.catch((err) => {
|
|
||||||
if (!service.running) return Effect.void;
|
|
||||||
return Effect.logError("[ConfigService] Error in consume loop", {error: err.message}).pipe(
|
|
||||||
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
step: () => undefined,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
if (msg === null) return;
|
||||||
|
|
||||||
|
yield* service.handleMessageEffect(msg);
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: () => consumer.acknowledge(msg),
|
||||||
|
catch: (cause) => configServiceError("consume-acknowledge", cause),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const runConfigServiceEffect = Effect.fn("ConfigService.run")(function* (
|
||||||
|
service: ConfigService,
|
||||||
|
) {
|
||||||
|
yield* service.loadFromDiskEffect;
|
||||||
|
|
||||||
|
const responseProducer = yield* Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
service.pubsub.createProducer<ConfigResponse>({
|
||||||
|
topic: topics.configResponse,
|
||||||
|
schema: ConfigResponseSchema,
|
||||||
|
}),
|
||||||
|
catch: (cause) => configServiceError("response-producer", cause),
|
||||||
|
});
|
||||||
|
yield* updateHandles(service.state, {responseProducer});
|
||||||
|
|
||||||
|
const pushProducer = yield* Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
service.pubsub.createProducer<ConfigPush>({
|
||||||
|
topic: topics.configPush,
|
||||||
|
schema: ConfigPushSchema,
|
||||||
|
}),
|
||||||
|
catch: (cause) => configServiceError("push-producer", cause),
|
||||||
|
});
|
||||||
|
yield* updateHandles(service.state, {pushProducer});
|
||||||
|
|
||||||
|
const consumer = yield* Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
service.pubsub.createConsumer<ConfigRequest>({
|
||||||
|
topic: topics.configRequest,
|
||||||
|
subscription: `${service.config.id}-config-request`,
|
||||||
|
schema: ConfigRequestSchema,
|
||||||
|
}),
|
||||||
|
catch: (cause) => configServiceError("consumer", cause),
|
||||||
|
});
|
||||||
|
const state = yield* updateHandles(service.state, {consumer});
|
||||||
|
|
||||||
|
yield* pushConfigWithStateEffect(state);
|
||||||
|
yield* Effect.log(`[ConfigService] Listening on ${topics.configRequest}`);
|
||||||
|
|
||||||
|
yield* Effect.whileLoop({
|
||||||
|
while: () => service.running,
|
||||||
|
body: () =>
|
||||||
|
consumeOnceEffect(service).pipe(
|
||||||
|
Effect.catch((err) => {
|
||||||
|
if (!service.running) return Effect.void;
|
||||||
|
return Effect.logError("[ConfigService] Error in consume loop", {error: err.message}).pipe(
|
||||||
|
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
step: () => undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export function makeConfigService(config: ConfigServiceConfig): ConfigService {
|
export function makeConfigService(config: ConfigServiceConfig): ConfigService {
|
||||||
const state = SynchronizedRef.makeUnsafe(initialState());
|
const state = SynchronizedRef.makeUnsafe(initialState());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue