import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ProviderItemId, ProviderKey, ToolName, } from "./ids"; import { definePlugin } from "./plugin"; import { makeTestExecutor } from "./test-config"; // A plugin's `removeConnection` runs INSIDE core's removal transaction, which is // what makes its database work atomic with the row deletions. The same property // makes anything reaching outside the database unsafe there: revoking a token at // the provider's API cannot be rolled back with the transaction, so an abort // leaves the connection restored and the token already dead. // // `ctx.afterCommit` is the way out, and these pin both directions of its // contract — it runs when the removal is durable, and it is discarded when the // removal is not. const INTEG = IntegrationSlug.make("vercel"); const TEMPLATE = AuthTemplateSlug.make("apiKey"); const revokingPlugin = (revoked: string[]) => definePlugin(() => { const store = new Map(); return { id: "demo" as const, credentialProviders: [ { key: ProviderKey.make("memory"), writable: true as const, get: (id: ProviderItemId) => Effect.sync(() => store.get(String(id)) ?? null), set: (id: ProviderItemId, value: string) => Effect.sync(() => { store.set(String(id), value); }), delete: (id: ProviderItemId) => Effect.sync(() => { store.delete(String(id)); }), }, ], storage: () => ({}), resolveTools: () => Effect.succeed({ tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }), invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), /** Stands in for "revoke the token at the provider's API" — the archetypal * irreversible, outside-the-database cleanup. */ removeConnection: ({ ctx, connection }) => ctx.afterCommit( Effect.sync(() => { revoked.push(String(connection.name)); }), ), extension: (ctx) => ({ seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), inTransaction: (effect: Effect.Effect) => ctx.transaction(effect), }), }; })(); const setup = (revoked: string[]) => makeTestExecutor({ plugins: [revokingPlugin(revoked)] as const }).pipe( Effect.tap((executor) => executor.demo.seed()), ); const REF = { owner: "org", integration: INTEG, name: ConnectionName.make("main"), } as const; describe("ctx.afterCommit inside a lifecycle hook", () => { it.effect("runs the deferred cleanup once the removal is durable", () => Effect.gen(function* () { const revoked: string[] = []; const executor = yield* setup(revoked); yield* executor.connections.create({ ...REF, template: TEMPLATE, value: "secret-token" }); yield* executor.connections.remove(REF); // Deferring must not mean dropping: an ordinary removal still revokes. expect(revoked).toEqual(["main"]); }), ); it.effect("discards the deferred cleanup when the removal rolls back", () => Effect.gen(function* () { const revoked: string[] = []; const executor = yield* setup(revoked); yield* executor.connections.create({ ...REF, template: TEMPLATE, value: "secret-token" }); const outcome = yield* Effect.exit( executor.demo.inTransaction( Effect.gen(function* () { yield* executor.connections.remove(REF); return yield* Effect.fail("rollback" as const); }), ), ); expect(Exit.isFailure(outcome)).toBe(true); // The connection survived, so revoking its token would have destroyed a // live credential with nothing left to undo it. const stillThere = yield* executor.connections.get(REF); expect(String(stillThere?.name)).toBe("main"); expect(revoked).toEqual([]); }), ); });