Use HashMap for config service state

This commit is contained in:
elpresidank 2026-06-04 06:22:35 -05:00
parent 48710a0518
commit 475bc3cb6c
2 changed files with 140 additions and 116 deletions

View file

@ -1896,6 +1896,30 @@ Notes:
- `cd ts && bun run lint` - `cd ts && bun run lint`
- `git diff --check` - `git diff --check`
### 2026-06-04: Config Service HashMap State Slice
- Status: migrated and package-verified.
- Completed:
- `ts/packages/flow/src/config/service.ts` now stores workspace and
namespace config state in nested `HashMap` values inside the existing
`SynchronizedRef`.
- Put/delete operations now update immutable `HashMap` snapshots with
`HashMap.set` and `HashMap.remove` instead of cloning and mutating native
`Map` instances.
- Persistence, config dump, list, and get-values handlers keep plain
`Record`/array shapes only at API and JSON boundaries, with deterministic
ordering applied while converting out of `HashMap`.
- The focused scan for native map state in `config/service.ts` is clean; the
remaining map matches in that file are `HashMap` and `SynchronizedRef`
operations.
- 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:
@ -1912,10 +1936,14 @@ Notes:
matches are legacy migration fallbacks, QA assertions, or the pre-paint matches are legacy migration fallbacks, QA assertions, or the pre-paint
host script. host script.
- Flow stateful services: - Flow stateful services:
- Config service, KnowledgeCore service, FlowManager, and Librarian - Config service operation dispatch, schema persistence, and nested
ref-backed state slices are complete. Follow-up service work should focus workspace state are complete: the long-lived config store now uses
on scoped layers, schedules where polling semantics allow, and managed `HashMap` inside `SynchronizedRef`, and plain records remain only at
persistence providers rather than direct mutable service fields. persistence/API boundaries.
- KnowledgeCore service, FlowManager, and Librarian ref-backed state slices
are complete. Follow-up service work should focus on scoped layers,
schedules where polling semantics allow, and managed persistence providers
rather than direct mutable service fields.
- Flow service startup facades now consistently use `ManagedRuntime`, and - Flow service startup facades now consistently use `ManagedRuntime`, and
local scripts should delegate to `runMain()` instead of adding local local scripts should delegate to `runMain()` instead of adding local
`.catch(console.error/process.exit)` wrappers. `.catch(console.error/process.exit)` wrappers.
@ -2023,14 +2051,18 @@ Notes:
- 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.
- ConfigService nested workspace config state now uses Effect `HashMap`.
JSON and API response helpers intentionally convert back to sorted plain
records/arrays at the boundary.
- Native `switch` statements are now clean in `ts/packages`; future branch - Native `switch` statements are now clean in `ts/packages`; future branch
drift should keep service dispatch on `effect/Match` or Schema tagged-union drift should keep service dispatch on `effect/Match` or Schema tagged-union
helpers. helpers.
- Client RPC/BaseApi connection-state fanout now uses - Client RPC/BaseApi connection-state fanout now uses
`effect/SubscriptionRef`; remaining gateway/client P1 work is broader API `effect/SubscriptionRef`; remaining gateway/client P1 work is broader API
design, not listener bookkeeping. design, not listener bookkeeping.
- Long-lived `Map` / `Set` state in ref-backed services can move toward - Long-lived `Map` / `Set` state in remaining ref-backed services can move
Effect collections later; local pure traversal maps/sets remain no-ops. toward Effect collections later; local pure traversal maps/sets remain
no-ops.
## Ranked Findings ## Ranked Findings

View file

@ -5,7 +5,7 @@
*/ */
import {NodeRuntime} from "@effect/platform-node"; import {NodeRuntime} from "@effect/platform-node";
import {Duration, Effect, Layer, ManagedRuntime, Match, SynchronizedRef} from "effect"; import {Duration, Effect, HashMap, Layer, ManagedRuntime, Match, Option, SynchronizedRef} from "effect";
import * as Predicate from "effect/Predicate"; import * as Predicate from "effect/Predicate";
import * as S from "effect/Schema"; import * as S from "effect/Schema";
import { import {
@ -70,13 +70,14 @@ interface ConfigValueLike {
readonly value: unknown; readonly value: unknown;
} }
type NamespaceStore = Map<string, unknown>; type NamespaceStore = HashMap.HashMap<string, unknown>;
type WorkspaceStore = Map<string, NamespaceStore>; type WorkspaceStore = HashMap.HashMap<string, NamespaceStore>;
type ConfigStore = HashMap.HashMap<string, WorkspaceStore>;
type WorkspaceSnapshot = Record<string, Record<string, Record<string, unknown>>>; type WorkspaceSnapshot = Record<string, Record<string, Record<string, unknown>>>;
interface ConfigServiceState { interface ConfigServiceState {
readonly version: number; readonly version: number;
readonly store: Map<string, WorkspaceStore>; readonly store: ConfigStore;
readonly consumer: BackendConsumer<ConfigRequest> | null; readonly consumer: BackendConsumer<ConfigRequest> | null;
readonly responseProducer: BackendProducer<ConfigResponse> | null; readonly responseProducer: BackendProducer<ConfigResponse> | null;
readonly pushProducer: BackendProducer<ConfigPush> | null; readonly pushProducer: BackendProducer<ConfigPush> | null;
@ -116,46 +117,46 @@ export interface ConfigService extends AsyncProcessorRuntime<ConfigServiceError>
const initialState = (): ConfigServiceState => ({ const initialState = (): ConfigServiceState => ({
version: 0, version: 0,
store: new Map<string, WorkspaceStore>(), store: HashMap.empty<string, WorkspaceStore>(),
consumer: null, consumer: null,
responseProducer: null, responseProducer: null,
pushProducer: null, pushProducer: null,
}); });
const cloneNamespaceStore = (source: NamespaceStore): NamespaceStore => { const getHashMapValue = <K, V>(store: HashMap.HashMap<K, V>, key: K): V | undefined =>
const next = new Map<string, unknown>(); Option.getOrUndefined(HashMap.get(store, key));
for (const [key, value] of source) {
next.set(key, value);
}
return next;
};
const cloneWorkspaceStore = (source: WorkspaceStore): WorkspaceStore => { const compareText = (left: string, right: string): number =>
const next = new Map<string, NamespaceStore>(); left.localeCompare(right);
for (const [namespace, subMap] of source) {
next.set(namespace, cloneNamespaceStore(subMap));
}
return next;
};
const cloneConfigStore = (source: Map<string, WorkspaceStore>): Map<string, WorkspaceStore> => { const compareWorkspace = (left: string, right: string): number =>
const next = new Map<string, WorkspaceStore>(); left === right
for (const [workspace, ws] of source) { ? 0
next.set(workspace, cloneWorkspaceStore(ws)); : left === DEFAULT_WORKSPACE
} ? -1
return next; : right === DEFAULT_WORKSPACE
}; ? 1
: compareText(left, right);
const workspaceEntries = (store: ConfigStore): ReadonlyArray<readonly [string, WorkspaceStore]> =>
HashMap.toEntries(store).sort(([left], [right]) => compareWorkspace(left, right));
const namespaceEntries = (store: WorkspaceStore): ReadonlyArray<readonly [string, NamespaceStore]> =>
HashMap.toEntries(store).sort(([left], [right]) => compareText(left, right));
const valueEntries = (store: NamespaceStore): ReadonlyArray<readonly [string, unknown]> =>
HashMap.toEntries(store).sort(([left], [right]) => compareText(left, right));
const toPersistedWorkspaces = ( const toPersistedWorkspaces = (
store: Map<string, WorkspaceStore>, store: ConfigStore,
): WorkspaceSnapshot => { ): WorkspaceSnapshot => {
const workspaces: WorkspaceSnapshot = {}; const workspaces: WorkspaceSnapshot = {};
for (const [workspace, ws] of store) { for (const [workspace, ws] of workspaceEntries(store)) {
const workspaceData: Record<string, Record<string, unknown>> = {}; const workspaceData: Record<string, Record<string, unknown>> = {};
for (const [namespace, subMap] of ws) { for (const [namespace, subMap] of namespaceEntries(ws)) {
const obj: Record<string, unknown> = {}; const obj: Record<string, unknown> = {};
for (const [key, value] of subMap) { for (const [key, value] of valueEntries(subMap)) {
obj[key] = value; obj[key] = value;
} }
workspaceData[namespace] = obj; workspaceData[namespace] = obj;
@ -166,34 +167,33 @@ const toPersistedWorkspaces = (
return workspaces; return workspaces;
}; };
const storeFromPersistedConfig = (parsed: PersistedConfig): Map<string, WorkspaceStore> => { const workspaceStoreFromPersistedNamespaces = (
const store = new Map<string, WorkspaceStore>(); namespaces: Record<string, Record<string, unknown>>,
): WorkspaceStore => {
let workspaceStore = HashMap.empty<string, NamespaceStore>();
for (const [namespace, obj] of Object.entries(namespaces)) {
let namespaceStore = HashMap.empty<string, unknown>();
for (const [key, value] of Object.entries(obj)) {
namespaceStore = HashMap.set(namespaceStore, key, value);
}
workspaceStore = HashMap.set(workspaceStore, namespace, namespaceStore);
}
return workspaceStore;
};
const storeFromPersistedConfig = (parsed: PersistedConfig): ConfigStore => {
let store = HashMap.empty<string, WorkspaceStore>();
if (parsed.workspaces !== undefined) { if (parsed.workspaces !== undefined) {
for (const [workspace, namespaces] of Object.entries(parsed.workspaces)) { for (const [workspace, namespaces] of Object.entries(parsed.workspaces)) {
const ws = new Map<string, NamespaceStore>(); store = HashMap.set(store, workspace, workspaceStoreFromPersistedNamespaces(namespaces));
for (const [namespace, obj] of Object.entries(namespaces)) {
const subMap = new Map<string, unknown>();
for (const [key, value] of Object.entries(obj)) {
subMap.set(key, value);
}
ws.set(namespace, subMap);
}
store.set(workspace, ws);
} }
return store; return store;
} }
const ws = new Map<string, NamespaceStore>(); return HashMap.set(store, DEFAULT_WORKSPACE, workspaceStoreFromPersistedNamespaces(parsed.data ?? {}));
for (const [namespace, obj] of Object.entries(parsed.data ?? {})) {
const subMap = new Map<string, unknown>();
for (const [key, value] of Object.entries(obj)) {
subMap.set(key, value);
}
ws.set(namespace, subMap);
}
store.set(DEFAULT_WORKSPACE, ws);
return store;
}; };
const optionalString = (value: unknown): string | undefined => const optionalString = (value: unknown): string | undefined =>
@ -266,38 +266,14 @@ const getWorkspaceStore = (
state: ConfigServiceState, state: ConfigServiceState,
workspace: string, workspace: string,
): WorkspaceStore | undefined => ): WorkspaceStore | undefined =>
state.store.get(workspace); getHashMapValue(state.store, workspace);
const getNamespaceStore = ( const getNamespaceStore = (
state: ConfigServiceState, state: ConfigServiceState,
workspace: string, workspace: string,
namespace: string, namespace: string,
): NamespaceStore | undefined => ): NamespaceStore | undefined =>
getWorkspaceStore(state, workspace)?.get(namespace); Option.flatMap(HashMap.get(state.store, workspace), HashMap.get(namespace)).pipe(Option.getOrUndefined);
const getOrCreateWorkspaceStore = (
store: Map<string, WorkspaceStore>,
workspace: string,
): WorkspaceStore => {
const existing = store.get(workspace);
if (existing !== undefined) return existing;
const created = new Map<string, NamespaceStore>();
store.set(workspace, created);
return created;
};
const getOrCreateNamespaceStore = (
store: Map<string, WorkspaceStore>,
workspace: string,
namespace: string,
): NamespaceStore => {
const ws = getOrCreateWorkspaceStore(store, workspace);
const existing = ws.get(namespace);
if (existing !== undefined) return existing;
const created = new Map<string, unknown>();
ws.set(namespace, created);
return created;
};
const configDumpForState = ( const configDumpForState = (
state: ConfigServiceState, state: ConfigServiceState,
@ -308,9 +284,9 @@ const configDumpForState = (
if (ws === undefined) return config; if (ws === undefined) return config;
for (const [namespace, subMap] of ws) { for (const [namespace, subMap] of namespaceEntries(ws)) {
const obj: Record<string, unknown> = {}; const obj: Record<string, unknown> = {};
for (const [key, value] of subMap) { for (const [key, value] of valueEntries(subMap)) {
obj[key] = value; obj[key] = value;
} }
config[namespace] = obj; config[namespace] = obj;
@ -412,7 +388,7 @@ const handleGetWithState = (
type: key.type, type: key.type,
key: key.key ?? "", key: key.key ?? "",
value: key.key !== undefined value: key.key !== undefined
? getNamespaceStore(state, workspace, key.type)?.get(key.key) ? getHashMapValue(getNamespaceStore(state, workspace, key.type) ?? HashMap.empty<string, unknown>(), key.key)
: undefined, : undefined,
})); }));
return {version: state.version, values}; return {version: state.version, values};
@ -429,13 +405,14 @@ const handleGetWithState = (
if (subMap !== undefined) { if (subMap !== undefined) {
if (keys.length === 1) { if (keys.length === 1) {
for (const [key, value] of subMap) { for (const [key, value] of valueEntries(subMap)) {
values[key] = value; values[key] = value;
} }
} else { } else {
for (const key of keys.slice(1)) { for (const key of keys.slice(1)) {
if (subMap.has(key)) { const value = getHashMapValue(subMap, key);
values[key] = subMap.get(key); if (value !== undefined || HashMap.has(subMap, key)) {
values[key] = value;
} }
} }
} }
@ -448,11 +425,15 @@ const applyPut = (
state: ConfigServiceState, state: ConfigServiceState,
values: ReadonlyArray<ConfigValueLike>, values: ReadonlyArray<ConfigValueLike>,
): ConfigServiceState => { ): ConfigServiceState => {
const store = cloneConfigStore(state.store); let store = state.store;
for (const item of values) { for (const item of values) {
getOrCreateNamespaceStore(store, item.workspace ?? DEFAULT_WORKSPACE, item.type) const workspace = item.workspace ?? DEFAULT_WORKSPACE;
.set(item.key, item.value); const workspaceStore = getHashMapValue(store, workspace) ?? HashMap.empty<string, NamespaceStore>();
const namespaceStore = getHashMapValue(workspaceStore, item.type) ?? HashMap.empty<string, unknown>();
const nextNamespaceStore = HashMap.set(namespaceStore, item.key, item.value);
const nextWorkspaceStore = HashMap.set(workspaceStore, item.type, nextNamespaceStore);
store = HashMap.set(store, workspace, nextWorkspaceStore);
} }
return { return {
@ -467,21 +448,27 @@ const applyDeleteObjectKeys = (
workspace: string, workspace: string,
keys: ReadonlyArray<ConfigKeyLike>, keys: ReadonlyArray<ConfigKeyLike>,
): ConfigServiceState => { ): ConfigServiceState => {
const store = cloneConfigStore(state.store); let store = state.store;
const ws = store.get(workspace); const ws = getHashMapValue(store, workspace);
if (ws !== undefined) { if (ws !== undefined) {
let nextWorkspaceStore = ws;
for (const key of keys) { for (const key of keys) {
if (key.key === undefined) { if (key.key === undefined) {
ws.delete(key.type); nextWorkspaceStore = HashMap.remove(nextWorkspaceStore, key.type);
} else { } else {
const ns = ws.get(key.type); const ns = getHashMapValue(nextWorkspaceStore, key.type);
ns?.delete(key.key); if (ns !== undefined) {
if (ns !== undefined && ns.size === 0) { const nextNamespaceStore = HashMap.remove(ns, key.key);
ws.delete(key.type); nextWorkspaceStore = HashMap.size(nextNamespaceStore) === 0
? HashMap.remove(nextWorkspaceStore, key.type)
: HashMap.set(nextWorkspaceStore, key.type, nextNamespaceStore);
} }
} }
} }
store = HashMap.set(store, workspace, nextWorkspaceStore);
} }
return { return {
@ -496,26 +483,31 @@ const applyDeleteStringKeys = (
workspace: string, workspace: string,
keys: ReadonlyArray<string>, keys: ReadonlyArray<string>,
): ConfigServiceState => { ): ConfigServiceState => {
const store = cloneConfigStore(state.store); let store = state.store;
const namespace = keys[0]; const namespace = keys[0];
const ws = store.get(workspace); const ws = getHashMapValue(store, workspace);
if (ws === undefined) return state; if (ws === undefined) return state;
let nextWorkspaceStore = ws;
if (keys.length === 1) { if (keys.length === 1) {
ws.delete(namespace); nextWorkspaceStore = HashMap.remove(nextWorkspaceStore, namespace);
} else { } else {
const subMap = ws.get(namespace); const subMap = getHashMapValue(nextWorkspaceStore, namespace);
if (subMap !== undefined) { if (subMap !== undefined) {
let nextNamespaceStore = subMap;
for (const key of keys.slice(1)) { for (const key of keys.slice(1)) {
subMap.delete(key); nextNamespaceStore = HashMap.remove(nextNamespaceStore, key);
}
if (subMap.size === 0) {
ws.delete(namespace);
} }
nextWorkspaceStore = HashMap.size(nextNamespaceStore) === 0
? HashMap.remove(nextWorkspaceStore, namespace)
: HashMap.set(nextWorkspaceStore, namespace, nextNamespaceStore);
} }
} }
store = HashMap.set(store, workspace, nextWorkspaceStore);
return { return {
...state, ...state,
store, store,
@ -588,14 +580,14 @@ const handleListWithState = (
if (namespace === undefined) { if (namespace === undefined) {
return { return {
version: state.version, version: state.version,
directory: ws !== undefined ? [...ws.keys()] : [], directory: ws !== undefined ? namespaceEntries(ws).map(([key]) => key) : [],
}; };
} }
const subMap = ws?.get(namespace); const subMap = ws === undefined ? undefined : getHashMapValue(ws, namespace);
return { return {
version: state.version, version: state.version,
directory: subMap !== undefined ? [...subMap.keys()] : [], directory: subMap !== undefined ? valueEntries(subMap).map(([key]) => key) : [],
}; };
}; };
@ -609,9 +601,9 @@ const handleGetValuesWithState = (
const values: Array<{type: string; key: string; value: unknown}> = []; const values: Array<{type: string; key: string; value: unknown}> = [];
if (ws !== undefined) { if (ws !== undefined) {
for (const [namespace, subMap] of ws) { for (const [namespace, subMap] of namespaceEntries(ws)) {
if (type.length > 0 && namespace !== type) continue; if (type.length > 0 && namespace !== type) continue;
for (const [key, value] of subMap) { for (const [key, value] of valueEntries(subMap)) {
values.push({type: namespace, key, value}); values.push({type: namespace, key, value});
} }
} }
@ -627,10 +619,10 @@ const handleGetValuesAllWorkspacesWithState = (
const type = requestType(request) ?? ""; const type = requestType(request) ?? "";
const values: Array<{workspace: string; type: string; key: string; value: unknown}> = []; const values: Array<{workspace: string; type: string; key: string; value: unknown}> = [];
for (const [workspace, ws] of state.store) { for (const [workspace, ws] of workspaceEntries(state.store)) {
for (const [namespace, subMap] of ws) { for (const [namespace, subMap] of namespaceEntries(ws)) {
if (type.length > 0 && namespace !== type) continue; if (type.length > 0 && namespace !== type) continue;
for (const [key, value] of subMap) { for (const [key, value] of valueEntries(subMap)) {
values.push({workspace, type: namespace, key, value}); values.push({workspace, type: namespace, key, value});
} }
} }
@ -840,7 +832,7 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService {
store: storeFromPersistedConfig(parsed), store: storeFromPersistedConfig(parsed),
})); }));
yield* Effect.log(`[ConfigService] Loaded persisted config (version=${next.version}, workspaces=${next.store.size})`); yield* Effect.log(`[ConfigService] Loaded persisted config (version=${next.version}, workspaces=${HashMap.size(next.store)})`);
}); });
service = Object.assign(base, { service = Object.assign(base, {