import { PolicyId, type ScopeId, type ToolId, type SecretId } from "@executor-js/sdk";import * as Atom from "effect/unstable/reactivity/Atom";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { ExecutorApiClient } from "./client";import { ReactivityKey } from "./reactivity-keys"; // ---------------------------------------------------------------------------// Scope — fetched from the server// --------------------------------------------------------------------------- export const scopeAtom = ExecutorApiClient.query("scope", "info", { timeToLive: "5 minutes", reactivityKeys: [ReactivityKey.scope],}); // ---------------------------------------------------------------------------// Query atoms — typed, cached, reactive// --------------------------------------------------------------------------- export const toolsAtom = (scopeId: ScopeId) => ExecutorApiClient.query("tools", "list", { params: { scopeId }, timeToLive: "30 seconds", reactivityKeys: [ReactivityKey.tools], }); /** Tools for a specific source */export const sourceToolsAtom = (sourceId: string, scopeId: ScopeId) => ExecutorApiClient.query("sources", "tools", { params: { scopeId, sourceId }, timeToLive: "30 seconds", reactivityKeys: [ReactivityKey.tools], }); export const toolSchemaAtom = (scopeId: ScopeId, toolId: ToolId) => ExecutorApiClient.query("tools", "schema", { params: { scopeId, toolId }, timeToLive: "1 minute", reactivityKeys: [ReactivityKey.tools], }); export const sourcesAtom = (scopeId: ScopeId) => ExecutorApiClient.query("sources", "list", { params: { scopeId }, timeToLive: "30 seconds", reactivityKeys: [ReactivityKey.sources], }); /** Single source by id — derived from the sources list */export const sourceAtom = (sourceId: string, scopeId: ScopeId) => Atom.mapResult(sourcesAtom(scopeId), (sources) => sources.find((s) => s.id === sourceId) ?? null); export const secretsAtom = (scopeId: ScopeId) => ExecutorApiClient.query("secrets", "list", { params: { scopeId }, timeToLive: "30 seconds", reactivityKeys: [ReactivityKey.secrets], }); export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) => ExecutorApiClient.query("secrets", "status", { params: { scopeId, secretId }, timeToLive: "15 seconds", reactivityKeys: [ReactivityKey.secrets], }); export const connectionsAtom = (scopeId: ScopeId) => ExecutorApiClient.query("connections", "list", { params: { scopeId }, timeToLive: "30 seconds", reactivityKeys: [ReactivityKey.connections], }); export const policiesAtom = (scopeId: ScopeId) => ExecutorApiClient.query("policies", "list", { params: { scopeId }, timeToLive: "30 seconds", reactivityKeys: [ReactivityKey.policies], }); // ---------------------------------------------------------------------------// Mutation atoms — reactivityKeys must be passed at call site (effect-atom// does not accept them at definition time). See `reactivity-keys.tsx` for the// canonical key arrays.// --------------------------------------------------------------------------- export const setSecret = ExecutorApiClient.mutation("secrets", "set"); export const removeSecret = ExecutorApiClient.mutation("secrets", "remove"); export const removeConnection = ExecutorApiClient.mutation("connections", "remove"); export const removeSource = ExecutorApiClient.mutation("sources", "remove"); export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); export const detectSource = ExecutorApiClient.mutation("sources", "detect"); // ---------------------------------------------------------------------------// OAuth — one atom pair drives sign-in for every plugin. The plugin's// `Add*Source` / `*SignInButton` component passes the `strategy` descriptor// (dynamic-dcr for MCP/GraphQL, authorization-code for OpenAPI/Google,// client-credentials for server-to-server openapi) in the start payload;// the server looks the plugin_id up on the session row at callback time.// --------------------------------------------------------------------------- export const probeOAuth = ExecutorApiClient.mutation("oauth", "probe"); export const startOAuth = ExecutorApiClient.mutation("oauth", "start"); export const completeOAuth = ExecutorApiClient.mutation("oauth", "complete"); export const cancelOAuth = ExecutorApiClient.mutation("oauth", "cancel"); export const createPolicy = ExecutorApiClient.mutation("policies", "create"); export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); export const removePolicy = ExecutorApiClient.mutation("policies", "remove"); // ---------------------------------------------------------------------------// Policies — optimistic surface. Reads go through `policiesOptimisticAtom`// (which layers in-flight transitions on top of `policiesAtom`), and writes// go through the matching `*PolicyOptimistic` mutation atoms. Each mutation// declares a reducer that produces the next array of rows; effect-atom's// `Atom.optimisticFn` handles transition tracking, waiting state, and the// post-commit refresh — including racing calls (latest reducer wins).// --------------------------------------------------------------------------- export const policiesOptimisticAtom = Atom.family((scopeId: ScopeId) => Atom.optimistic(policiesAtom(scopeId)),); export const createPolicyOptimistic = Atom.family((scopeId: ScopeId) => policiesOptimisticAtom(scopeId).pipe( Atom.optimisticFn({ reducer: (current, arg) => AsyncResult.map(current, (rows) => [ { id: PolicyId.make(`pending-${Math.random().toString(36).slice(2)}`), scopeId, pattern: arg.payload.pattern, action: arg.payload.action, // Empty string sorts before any fractional-indexing key, so // the placeholder lands at the top until the server returns // the canonical key. position: "", createdAt: Date.now(), updatedAt: Date.now(), }, ...rows, ]), fn: createPolicy, }), ),); export const updatePolicyOptimistic = Atom.family((_scopeId: ScopeId) => policiesOptimisticAtom(_scopeId).pipe( Atom.optimisticFn({ reducer: (current, arg) => AsyncResult.map(current, (rows) => rows.map((r) => r.id === arg.params.policyId ? { ...r, ...(arg.payload.action !== undefined ? { action: arg.payload.action } : {}), ...(arg.payload.pattern !== undefined ? { pattern: arg.payload.pattern } : {}), ...(arg.payload.position !== undefined ? { position: arg.payload.position } : {}), } : r, ), ), fn: updatePolicy, }), ),); export const removePolicyOptimistic = Atom.family((scopeId: ScopeId) => policiesOptimisticAtom(scopeId).pipe( Atom.optimisticFn({ reducer: (current, arg) => AsyncResult.map(current, (rows) => rows.filter((r) => r.id !== arg.params.policyId)), fn: removePolicy, }), ),);
import { PolicyId, type ScopeId, type ToolId, type SecretId } from "@executor-js/sdk";
import * as Atom from "effect/unstable/reactivity/Atom";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import { ExecutorApiClient } from "./client";
import { ReactivityKey } from "./reactivity-keys";
// ---------------------------------------------------------------------------
// Scope — fetched from the server
// ---------------------------------------------------------------------------
export const scopeAtom = ExecutorApiClient.query("scope", "info", {
timeToLive: "5 minutes",
reactivityKeys: [ReactivityKey.scope],
});
// ---------------------------------------------------------------------------
// Query atoms — typed, cached, reactive
// ---------------------------------------------------------------------------
export const toolsAtom = (scopeId: ScopeId) =>
ExecutorApiClient.query("tools", "list", {
params: { scopeId },
timeToLive: "30 seconds",
reactivityKeys: [ReactivityKey.tools],
});
/** Tools for a specific source */
export const sourceToolsAtom = (sourceId: string, scopeId: ScopeId) =>
ExecutorApiClient.query("sources", "tools", {
params: { scopeId, sourceId },
timeToLive: "30 seconds",
reactivityKeys: [ReactivityKey.tools],
});
export const toolSchemaAtom = (scopeId: ScopeId, toolId: ToolId) =>
ExecutorApiClient.query("tools", "schema", {
params: { scopeId, toolId },
timeToLive: "1 minute",
reactivityKeys: [ReactivityKey.tools],
});
export const sourcesAtom = (scopeId: ScopeId) =>
ExecutorApiClient.query("sources", "list", {
params: { scopeId },
timeToLive: "30 seconds",
reactivityKeys: [ReactivityKey.sources],
});
/** Single source by id — derived from the sources list */
export const sourceAtom = (sourceId: string, scopeId: ScopeId) =>
Atom.mapResult(sourcesAtom(scopeId), (sources) => sources.find((s) => s.id === sourceId) ?? null);
export const secretsAtom = (scopeId: ScopeId) =>
ExecutorApiClient.query("secrets", "list", {
params: { scopeId },
timeToLive: "30 seconds",
reactivityKeys: [ReactivityKey.secrets],
});
export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) =>
ExecutorApiClient.query("secrets", "status", {
params: { scopeId, secretId },
timeToLive: "15 seconds",
reactivityKeys: [ReactivityKey.secrets],
});
export const connectionsAtom = (scopeId: ScopeId) =>
ExecutorApiClient.query("connections", "list", {
params: { scopeId },
timeToLive: "30 seconds",
reactivityKeys: [ReactivityKey.connections],
});
export const policiesAtom = (scopeId: ScopeId) =>
ExecutorApiClient.query("policies", "list", {
params: { scopeId },
timeToLive: "30 seconds",
reactivityKeys: [ReactivityKey.policies],
});
// ---------------------------------------------------------------------------
// Mutation atoms — reactivityKeys must be passed at call site (effect-atom
// does not accept them at definition time). See `reactivity-keys.tsx` for the
// canonical key arrays.
// ---------------------------------------------------------------------------
export const setSecret = ExecutorApiClient.mutation("secrets", "set");
export const removeSecret = ExecutorApiClient.mutation("secrets", "remove");
export const removeConnection = ExecutorApiClient.mutation("connections", "remove");
export const removeSource = ExecutorApiClient.mutation("sources", "remove");
export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");
export const detectSource = ExecutorApiClient.mutation("sources", "detect");
// ---------------------------------------------------------------------------
// OAuth — one atom pair drives sign-in for every plugin. The plugin's
// `Add*Source` / `*SignInButton` component passes the `strategy` descriptor
// (dynamic-dcr for MCP/GraphQL, authorization-code for OpenAPI/Google,
// client-credentials for server-to-server openapi) in the start payload;
// the server looks the plugin_id up on the session row at callback time.
// ---------------------------------------------------------------------------
export const probeOAuth = ExecutorApiClient.mutation("oauth", "probe");
export const startOAuth = ExecutorApiClient.mutation("oauth", "start");
export const completeOAuth = ExecutorApiClient.mutation("oauth", "complete");
export const cancelOAuth = ExecutorApiClient.mutation("oauth", "cancel");
export const createPolicy = ExecutorApiClient.mutation("policies", "create");
export const updatePolicy = ExecutorApiClient.mutation("policies", "update");
export const removePolicy = ExecutorApiClient.mutation("policies", "remove");
// ---------------------------------------------------------------------------
// Policies — optimistic surface. Reads go through `policiesOptimisticAtom`
// (which layers in-flight transitions on top of `policiesAtom`), and writes
// go through the matching `*PolicyOptimistic` mutation atoms. Each mutation
// declares a reducer that produces the next array of rows; effect-atom's
// `Atom.optimisticFn` handles transition tracking, waiting state, and the
// post-commit refresh — including racing calls (latest reducer wins).
// ---------------------------------------------------------------------------
export const policiesOptimisticAtom = Atom.family((scopeId: ScopeId) =>
Atom.optimistic(policiesAtom(scopeId)),
);
export const createPolicyOptimistic = Atom.family((scopeId: ScopeId) =>
policiesOptimisticAtom(scopeId).pipe(
Atom.optimisticFn({
reducer: (current, arg) =>
AsyncResult.map(current, (rows) => [
{
id: PolicyId.make(`pending-${Math.random().toString(36).slice(2)}`),
scopeId,
pattern: arg.payload.pattern,
action: arg.payload.action,
// Empty string sorts before any fractional-indexing key, so
// the placeholder lands at the top until the server returns
// the canonical key.
position: "",
createdAt: Date.now(),
updatedAt: Date.now(),
},
...rows,
]),
fn: createPolicy,
}),
),
);
export const updatePolicyOptimistic = Atom.family((_scopeId: ScopeId) =>
policiesOptimisticAtom(_scopeId).pipe(
Atom.optimisticFn({
reducer: (current, arg) =>
AsyncResult.map(current, (rows) =>
rows.map((r) =>
r.id === arg.params.policyId
? {
...r,
...(arg.payload.action !== undefined ? { action: arg.payload.action } : {}),
...(arg.payload.pattern !== undefined ? { pattern: arg.payload.pattern } : {}),
...(arg.payload.position !== undefined ? { position: arg.payload.position } : {}),
}
: r,
),
),
fn: updatePolicy,
}),
),
);
export const removePolicyOptimistic = Atom.family((scopeId: ScopeId) =>
policiesOptimisticAtom(scopeId).pipe(
Atom.optimisticFn({
reducer: (current, arg) =>
AsyncResult.map(current, (rows) => rows.filter((r) => r.id !== arg.params.policyId)),
fn: removePolicy,
}),
),
);