// --------------------------------------------------------------------------- // @executor-js/sdk/promise — thin Promise façade over the Effect SDK. // // Consumer goal: use executors + plugins without touching Effect. The // façade wraps `createExecutor` so it returns a Promise, and proxies // every method on the returned executor to unwrap its Effect into a // Promise. Plugin factories are Effect-native but consumers never see // that — the proxy flattens plugin extension methods too. // // Not a goal: authoring plugins in Promise style. The plugin model // (storage, schema, staticIntegrations, Effect ctx) is Effect-only. Bring // your own `@executor-js/plugin-*` from the Effect side. // --------------------------------------------------------------------------- import { Brand, Effect } from "effect"; import { collectTables, createExecutor as createEffectExecutor, type Executor as EffectExecutor, type InvokeOptions as EffectInvokeOptions, type OnElicitation, } from "./executor"; import type { ElicitationContext, ElicitationResponse } from "./elicitation"; import type { FumaDb, FumaTables } from "./fuma-runtime"; import { Subject, Tenant } from "./ids"; import type { AnyPlugin } from "./plugin"; import type { CredentialProvider } from "./provider"; // --------------------------------------------------------------------------- // Types // // Promise consumers shouldn't need to construct Effect `Brand`s to call into // the executor — branded ids (`SecretId`, `ScopeId`, `ToolId`, `PolicyId`, // `ConnectionId`) are typed as `string & Brand<...>` on the Effect side, but // at runtime they're plain strings. `Unbrand` strips brand tags from // parameter types (recursively, so it walks into object fields like // `secrets.set({ id, scope })`) so consumers can pass plain strings. Return // types are passed through unchanged — caller code that reads `.id` etc. // off a returned ref still gets the branded type for use as an opaque token. // --------------------------------------------------------------------------- type Unbrand = T extends Brand.Brand ? string : T extends readonly (infer U)[] ? readonly Unbrand[] : T extends ReadonlyMap ? ReadonlyMap, Unbrand> : T extends ReadonlySet ? ReadonlySet> : T extends Date ? T : T extends (...args: infer A) => infer R ? (...args: { [I in keyof A]: Unbrand }) => Unbrand : T extends object ? { readonly [K in keyof T]: Unbrand } : T; export type PromiseOnElicitation = | "accept-all" | ((ctx: Unbrand) => ElicitationResponse | Promise); export interface PromiseInvokeOptions { readonly onElicitation?: PromiseOnElicitation; } type PromisifiedArg = T extends EffectInvokeOptions | undefined ? PromiseInvokeOptions | undefined : Unbrand; type PromisifiedArgs = { [I in keyof TArgs]: PromisifiedArg; }; export type Promisified = T extends (...args: infer A) => Effect.Effect ? (...args: PromisifiedArgs) => Promise : T extends readonly unknown[] ? T : T extends object ? { readonly [K in keyof T]: Promisified } : T; export type Executor = Promisified< EffectExecutor >; export interface ExecutorConfig { /** The org / workspace this executor binds to. Optional — defaults to * `"default-tenant"`. `owner: "org"` rows file here. */ readonly tenant?: string; /** The acting member. Omit for a pure-org executor (no `owner:"user"`). */ readonly subject?: string; readonly plugins?: TPlugins; /** * Config-level credential providers, merged with every * `plugin.credentialProviders`. Config providers register first, so the * default (first writable) store is selected from them when present. A * writable provider is required before `connections.create({ value })` can * store an inline credential. Providers are Effect-native objects (their * `get`/`set` return `Effect`s) — bring them from `@executor-js/sdk/core`. */ readonly providers?: readonly CredentialProvider[]; /** * FumaDB ORM handle, or a factory that receives the executor-owned table * map. Public consumers usually want the factory form so `collectTables()` * stays inside `createExecutor`. */ readonly db?: | FumaDb | { readonly db: FumaDb; readonly close?: () => Promise | void } | ((config: { readonly tables: FumaTables }) => | FumaDb | { readonly db: FumaDb; readonly close?: () => Promise | void } | Promise< | FumaDb | { readonly db: FumaDb; readonly close?: () => Promise | void; } >); /** * How to respond when a tool requests user input mid-invocation. Pass * `"accept-all"` for tests / non-interactive hosts, or a handler * `(ctx) => Promise` for interactive ones. * Required at construction so per-invoke calls don't have to thread * an options arg. */ readonly onElicitation: PromiseOnElicitation; } // --------------------------------------------------------------------------- // Promisify proxy — walks nested objects, converts Effect-returning methods // into Promise-returning methods. Non-Effect return values pass through. // --------------------------------------------------------------------------- const isPlainObject = (v: unknown): v is Record => v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date) && !(v instanceof Promise); const isPromiseOnElicitation = (value: unknown): value is PromiseOnElicitation => value === "accept-all" || typeof value === "function"; const toEffectOnElicitation = (handler: PromiseOnElicitation): OnElicitation => handler === "accept-all" ? "accept-all" : (ctx) => Effect.promise(() => Promise.resolve(handler(ctx))); const adaptPromiseInvokeOptions = (value: unknown): unknown => { if (!isPlainObject(value) || !Object.hasOwn(value, "onElicitation")) return value; const onElicitation = value.onElicitation; if (onElicitation === undefined || !isPromiseOnElicitation(onElicitation)) return value; return { ...value, onElicitation: toEffectOnElicitation(onElicitation), }; }; const adaptPromiseArgs = (args: readonly unknown[]): unknown[] => args.map((arg) => adaptPromiseInvokeOptions(arg)); const promisifyDeep = (value: T): Promisified => { if (typeof value === "function") { return ((...args: unknown[]) => { const result = (value as (...a: unknown[]) => unknown).apply( undefined, adaptPromiseArgs(args), ); if (Effect.isEffect(result)) { return Effect.runPromise(result as Effect.Effect); } return result; }) as Promisified; } if (!isPlainObject(value)) return value as Promisified; return new Proxy(value, { get(target, prop, receiver) { const v = Reflect.get(target, prop, receiver); if (typeof v === "function") { return (...args: unknown[]) => { const result = (v as (...a: unknown[]) => unknown).apply(target, adaptPromiseArgs(args)); if (Effect.isEffect(result)) { return Effect.runPromise(result as Effect.Effect); } return result; }; } if (isPlainObject(v)) return promisifyDeep(v); return v; }, }) as Promisified; }; // --------------------------------------------------------------------------- // createExecutor — Promise wrapper over the Effect createExecutor. // --------------------------------------------------------------------------- export const createExecutor = async ( config: ExecutorConfig, ): Promise> => { const plugins = (config?.plugins ?? []) as TPlugins; const db = typeof config.db === "function" ? await config.db({ tables: collectTables() }) : config.db; const effectConfig = { tenant: Tenant.make(config.tenant ?? "default-tenant"), ...(config.subject !== undefined ? { subject: Subject.make(config.subject) } : {}), plugins, ...(config.providers ? { providers: config.providers } : {}), onElicitation: toEffectOnElicitation(config.onElicitation), ...(db ? { db } : {}), }; // The SDK has no observability requirement; storage failures surface // as raw `StorageError` / `UniqueViolationError` in the typed channel. // `Effect.runPromise` turns them into Promise rejections — consumers // get the tagged error as the rejected value. See // notes/promise-sdk-typed-errors.md for the planned `runPromiseExit` // rewrite that exposes the full error union to consumers. const effectExecutor = await Effect.runPromise(createEffectExecutor(effectConfig)); const executor = promisifyDeep(effectExecutor) as Executor; return { ...executor, close: async () => { await Effect.runPromise(effectExecutor.close()); }, } as Executor; };