import { Context, Effect, Layer } from "effect"; import { withQueryContext } from "@executor-js/fumadb/query"; import { collectTables, createExecutor, type Executor, type ExecutorConfig } from "./executor"; import type { FumaDb } from "./fuma-runtime"; import { ProviderItemId, ProviderKey, Subject, Tenant } from "./ids"; import { definePlugin, type AnyPlugin } from "./plugin"; import type { ExecutorOwnerPolicyContext } from "./owner-policy"; import type { CredentialProvider } from "./provider"; import type { SqliteTestFumaDb } from "./sqlite-test-db"; // --------------------------------------------------------------------------- // makeTestConfig — build an ExecutorConfig backed by an in-memory FumaDB. // For unit tests, plugin authors validating their plugin, REPL experimentation. // // Defaults to a single tenant ("test-tenant") with a bound subject // ("test-subject"). Tests that need a pure-org executor can pass `subject: // null` via a spread override. // --------------------------------------------------------------------------- export type TestDatabaseBackend = "sqlite"; export type TestFumaDb = Pick & { readonly warm: () => Promise; }; const makeLazyTestFumaDb = (options: { readonly tables: ReturnType; readonly backend: TestDatabaseBackend; readonly dataDir?: string; }): TestFumaDb => { let started: Promise | undefined; const start = () => { if (!started) { started = import("./sqlite-test-db").then(({ createSqliteTestFumaDb }) => createSqliteTestFumaDb({ tables: options.tables, namespace: "executor_test", path: options.dataDir ? `${options.dataDir}/test.db` : undefined, }), ); } return started; }; const internal: FumaDb["internal"] = { tables: options.tables, count: async (table, value) => (await start()).db.internal.count(table, value), create: async (table, values) => (await start()).db.internal.create(table, values), createMany: async (table, values) => (await start()).db.internal.createMany(table, values), deleteMany: async (table, value) => (await start()).db.internal.deleteMany(table, value), findFirst: async (table, value) => (await start()).db.internal.findFirst(table, value), findMany: async (table, value) => (await start()).db.internal.findMany(table, value), transaction: async (run) => (await start()).db.internal.transaction(run), updateMany: async (table, value) => (await start()).db.internal.updateMany(table, value), upsert: async (table, value) => (await start()).db.internal.upsert(table, value), }; const queryMethods = new Set([ "count", "create", "createMany", "deleteMany", "findFirst", "findMany", "transaction", "updateMany", "upsert", ]); const makeDb = (context?: ExecutorOwnerPolicyContext): FumaDb => new Proxy( { internal: context === undefined ? internal : { ...internal, context } }, { get(target, prop) { if (prop === "internal") return target.internal; if (prop === "withContext") { return (nextContext: ExecutorOwnerPolicyContext) => makeDb(nextContext); } if (!queryMethods.has(prop)) return undefined; return async (...args: unknown[]) => { const actual = await start(); const actualDb = context === undefined ? actual.db : withQueryContext(actual.db, context); const method = Reflect.get(actualDb, prop) as (...innerArgs: unknown[]) => unknown; return method.apply(actualDb, args); }; }, }, ) as FumaDb; const db = makeDb(); return { db, warm: async () => { await start(); }, close: async () => { if (!started) return; await (await started).close(); }, }; }; /** The OAuth callback the in-memory test executor advertises. The OAuth flow * tests exercise `start` (authorization_code), which now REQUIRES an explicit * `redirectUri` — there is no localhost default. The test authorization server * accepts any redirect_uri for an unregistered client, so this stable value is * what the test AS echoes back. Tests asserting the "missing redirectUri fails * loudly" path override this with `redirectUri: null`. */ export const TEST_OAUTH_REDIRECT_URI = "http://localhost/oauth/callback"; export type TestConfigOptions = { readonly tenant?: string; readonly subject?: string | null; readonly plugins?: TPlugins; readonly backend?: TestDatabaseBackend; readonly dataDir?: string; readonly coreTools?: ExecutorConfig["coreTools"]; /** Override the OAuth callback URL. Pass `null` to construct an executor with * no OAuth callback (exercises the fail-loud redirect path). */ readonly redirectUri?: string | null; readonly oauthCallbackStateOrgSlug?: string; }; export const makeTestConfig = ( options?: TestConfigOptions, ): Omit, "db"> & { readonly db: FumaDb; readonly testDb: TestFumaDb; } => { const tenant = options?.tenant ?? "test-tenant"; const subject = options?.subject === undefined ? "test-subject" : options.subject; const tables = collectTables(); const testDb = makeLazyTestFumaDb({ tables, backend: options?.backend ?? "sqlite", dataDir: options?.dataDir, }); const db = withQueryContext(testDb.db, { tenant, subject, } satisfies ExecutorOwnerPolicyContext); // EXPLICIT OAuth callback: default to a stable test URL so the redirect flow // tests work without the removed localhost default; `redirectUri: null` omits // it so a test can exercise the fail-loud "no callback configured" path. const redirectUri = options?.redirectUri === undefined ? TEST_OAUTH_REDIRECT_URI : options.redirectUri; return { tenant: Tenant.make(tenant), ...(subject != null ? { subject: Subject.make(subject) } : {}), db, plugins: options?.plugins, coreTools: options?.coreTools, testDb, // Tests default to auto-accepting elicitation prompts. onElicitation: "accept-all", ...(redirectUri != null ? { redirectUri } : {}), oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug, }; }; export interface TestWorkspaceHarness< TPlugins extends readonly AnyPlugin[] = readonly AnyPlugin[], > { readonly config: ExecutorConfig & { readonly testDb: TestFumaDb }; readonly executor: Executor; readonly testDb: TestFumaDb; readonly tenant: string; readonly subject: string | null; } export class TestWorkspace extends Context.Service()( "executor-sdk/TestWorkspace", ) { static readonly current = < const TPlugins extends readonly AnyPlugin[] = readonly AnyPlugin[], >() => Effect.gen(function* () { const workspace = yield* TestWorkspace; return workspace as TestWorkspaceHarness; }); } export const makeTestWorkspaceHarness = ( options?: TestConfigOptions, ) => Effect.acquireRelease( Effect.gen(function* () { const config = makeTestConfig(options); const executor = yield* createExecutor(config); return { config, executor, testDb: config.testDb, tenant: String(config.tenant), subject: config.subject != null ? String(config.subject) : null, } as const; }), ({ executor, testDb }) => executor .close() .pipe( Effect.ignore, Effect.andThen(Effect.promise(() => testDb.close()).pipe(Effect.ignore)), ), ); export const makeTestWorkspaceLayer = ( options?: TestConfigOptions, ) => Layer.effect(TestWorkspace)( makeTestWorkspaceHarness(options).pipe( Effect.tap(({ testDb }) => Effect.promise(() => testDb.warm())), ), ); export const makeTestExecutor = ( options?: TestConfigOptions, ) => makeTestWorkspaceHarness(options).pipe(Effect.map(({ executor }) => executor)); /** Built-in in-memory writable credential provider, contributed as a plugin * so tests always have a default writable store for inline connection values. */ export const memoryCredentialsPlugin = definePlugin(() => { const store = new Map(); const provider: CredentialProvider = { key: ProviderKey.make("memory"), writable: true, get: (id) => Effect.sync(() => store.get(String(id)) ?? null), set: (id, value) => Effect.sync(() => { store.set(String(id), value); }), delete: (id) => Effect.sync(() => { store.delete(String(id)); }), list: () => Effect.sync(() => Array.from(store.keys()).map((key) => ({ id: ProviderItemId.make(key), name: key, })), ), }; return { id: "memory-credentials" as const, storage: () => ({}), credentialProviders: [provider], }; }); // Back-compat alias removed: v1 `memorySecretsPlugin` is gone (no secrets).