diff --git a/infrastructure/lib/problem-deploy/handlers/participant-handler/coordination-plugin-loader.ts b/infrastructure/lib/problem-deploy/handlers/participant-handler/coordination-plugin-loader.ts new file mode 100644 index 000000000..28a77c9c9 --- /dev/null +++ b/infrastructure/lib/problem-deploy/handlers/participant-handler/coordination-plugin-loader.ts @@ -0,0 +1,100 @@ +import type { CoordinationContext, CoordinationPlugin } from "@tenkacloud/coordination-plugin-sdk"; +import { + type CoordinationDispatchInput, + type CoordinationDispatchOutcome, + dispatchCoordinationOp, + projectCoordinationForTeam, +} from "./coordination-dispatch.js"; +import type { CoordinationStoreDeps } from "./coordination-store.js"; + +/** + * ADR-028 D6 (#1420): 問題が同梱する coordination plugin の **動的 import loader**。 + * + * 静的レジストリにすると platform が各問題に結合し、 community が coordination 付き問題を + * 追加するたびに platform の再デプロイが要る。 それは「問題は plugin、 platform は host」を壊し + * 問題カタログの community moat を殺す。 そこで plugin は問題 payload (ADR-008 の S3 配信経路) から + * 取得し runtime に `import()` で動的 load する — platform リリース不要で community が拡張できる。 + * + * 実 import 関数は {@link PluginImporter} として注入する (= 本番は S3 から materialize した module を + * import、 test は fake)。 別 isolate sandbox は立てない (cost/複雑度、 ADR-028 D6): plugin の hook は + * SDK 契約上すべて純関数 (AWS SDK / fetch 非依存) で、 bug は当該 event の 1 row に閉じ + * (optimistic lock + DDB write fail で停止)、 platform 全体には波及しない。 + */ + +/** plugin module を解決する関数。 本番は S3 payload を materialize した module ref を import()。 */ +export type PluginImporter = (moduleRef: string) => Promise; + +/** + * value が {@link CoordinationPlugin} 契約 (initialState + 3 必須 hook) を満たすか構造判定する。 + * tick は optional なので問わない。 動的 load した未知 module を dispatcher に渡す前の門番。 + */ +export function isCoordinationPlugin( + value: unknown, +): value is CoordinationPlugin { + if (typeof value !== "object" || value === null) return false; + const p = value as Record; + return ( + typeof p.initialState === "function" && + typeof p.validateOp === "function" && + typeof p.applyOp === "function" && + typeof p.projectForTeam === "function" + ); +} + +/** + * plugin module を動的 import し、 default export (無ければ module 自体) が契約を満たせば返す。 + * import 失敗 (= module 不在 / 構文エラー) や契約不一致は **null** を返す (= caller が safe fallback)。 + * platform は問題依存の意味論を持たないので、 buggy / 未対応な問題でも participant API を壊さない。 + */ +export async function loadCoordinationPlugin( + importer: PluginImporter, + moduleRef: string, +): Promise | null> { + let mod: unknown; + try { + mod = await importer(moduleRef); + } catch { + return null; + } + const candidate = (mod as { default?: unknown } | null)?.default ?? mod; + return isCoordinationPlugin(candidate) ? candidate : null; +} + +/** plugin が load できなかった (= 問題が coordination 未対応 / 壊れている) ときの outcome。 */ +export type PluginUnavailable = { readonly kind: "plugin_unavailable" }; + +/** + * 動的 load → dispatch を 1 経路にした orchestration (ADR-028 D6: 「plugin を load して dispatchOp」)。 + * plugin が load できなければ `plugin_unavailable` を返し、 副作用 (DDB) には触れない。 + */ +export async function loadAndDispatchCoordinationOp( + importer: PluginImporter, + moduleRef: string, + store: CoordinationStoreDeps, + input: CoordinationDispatchInput, +): Promise { + const plugin = await loadCoordinationPlugin(importer, moduleRef); + if (!plugin) return { kind: "plugin_unavailable" }; + return dispatchCoordinationOp(store, plugin, input); +} + +/** + * 動的 load → projection の read 経路 (書き込みなし、 portal polling 用)。 plugin が load できなければ + * `fallbackProjection` を返す (= 他 team の機密を出さない安全な既定)。 + */ +export async function loadAndProjectCoordinationForTeam( + importer: PluginImporter, + moduleRef: string, + store: CoordinationStoreDeps, + input: { + readonly tenantId: string; + readonly eventId: string; + readonly teamId: string; + readonly ctx: CoordinationContext; + readonly fallbackProjection: unknown; + }, +): Promise { + const plugin = await loadCoordinationPlugin(importer, moduleRef); + if (!plugin) return input.fallbackProjection; + return projectCoordinationForTeam(store, plugin, input); +} diff --git a/infrastructure/test/problem-deploy/coordination-plugin-loader.test.ts b/infrastructure/test/problem-deploy/coordination-plugin-loader.test.ts new file mode 100644 index 000000000..702d7afaa --- /dev/null +++ b/infrastructure/test/problem-deploy/coordination-plugin-loader.test.ts @@ -0,0 +1,164 @@ +import { GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb"; +import type { CoordinationPlugin } from "@tenkacloud/coordination-plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import { + isCoordinationPlugin, + loadAndDispatchCoordinationOp, + loadAndProjectCoordinationForTeam, + loadCoordinationPlugin, + type PluginImporter, +} from "../../lib/problem-deploy/handlers/participant-handler/coordination-plugin-loader.js"; +import type { CoordinationStoreDeps } from "../../lib/problem-deploy/handlers/participant-handler/coordination-store.js"; + +/** + * ADR-028 D6 (#1420): 問題同梱 coordination plugin の動的 import loader を pin する。 + * import 失敗 / 契約不一致 → safe fallback、 load 成功 → 既存 dispatcher へ委譲、 を観測する。 + */ + +interface CounterState { + readonly count: number; +} +type CounterOp = { kind: "inc" } | { kind: "bad" }; + +const counter: CoordinationPlugin = { + initialState: () => ({ count: 0 }), + validateOp: (_s, _t, op) => (op.kind === "bad" ? { ok: false, error: "bad_op" } : { ok: true }), + applyOp: (s) => ({ count: s.count + 1 }), + projectForTeam: (s) => ({ count: s.count }), +}; + +const ctx = { eventId: "e1", teamIds: ["t1", "t2"] }; +const dispatchInput = { + tenantId: "tn1", + eventId: "e1", + teamId: "t1", + op: { kind: "inc" } as CounterOp, + ctx, + fallbackProjection: { count: -1 }, + nowIso: "2026-06-01T00:00:00Z", +}; + +/** GetCommand → getItem を返し、 PutCommand → ok。 conflict 経路は dispatcher 側 test で網羅済み。 */ +function fakeStore(getItem?: Record): CoordinationStoreDeps { + const send = vi.fn(async (cmd: unknown) => { + if (cmd instanceof GetCommand) return { Item: getItem }; + if (cmd instanceof PutCommand) return {}; + throw new Error("unexpected command"); + }); + return { ddb: { send } as never, tableName: "Deployments" }; +} + +/** moduleRef を無視して固定 module を返す importer。 */ +const importerOf = + (mod: unknown): PluginImporter => + async () => + mod; +const throwingImporter: PluginImporter = async () => { + throw new Error("module not found"); +}; + +describe("isCoordinationPlugin", () => { + it("should accept an object with all required hooks", () => { + expect(isCoordinationPlugin(counter)).toBe(true); + }); + + it("should reject null and non-objects", () => { + expect(isCoordinationPlugin(null)).toBe(false); + expect(isCoordinationPlugin("plugin")).toBe(false); + expect(isCoordinationPlugin(42)).toBe(false); + }); + + it("should reject an object missing any required hook", () => { + expect(isCoordinationPlugin({ validateOp() {}, applyOp() {}, projectForTeam() {} })).toBe( + false, + ); + expect(isCoordinationPlugin({ initialState() {}, applyOp() {}, projectForTeam() {} })).toBe( + false, + ); + expect(isCoordinationPlugin({ initialState() {}, validateOp() {}, projectForTeam() {} })).toBe( + false, + ); + expect(isCoordinationPlugin({ initialState() {}, validateOp() {}, applyOp() {} })).toBe(false); + }); +}); + +describe("loadCoordinationPlugin", () => { + it("should return the plugin from a default export", async () => { + expect(await loadCoordinationPlugin(importerOf({ default: counter }), "ref")).toBe(counter); + }); + + it("should return the plugin when the module itself is the plugin", async () => { + expect(await loadCoordinationPlugin(importerOf(counter), "ref")).toBe(counter); + }); + + it("should return null when the importer throws", async () => { + expect(await loadCoordinationPlugin(throwingImporter, "ref")).toBeNull(); + }); + + it("should return null when the module is null or fails the contract", async () => { + expect(await loadCoordinationPlugin(importerOf(null), "ref")).toBeNull(); + expect(await loadCoordinationPlugin(importerOf({ default: {} }), "ref")).toBeNull(); + }); +}); + +describe("loadAndDispatchCoordinationOp", () => { + it("should load the plugin and dispatch a valid op", async () => { + const outcome = await loadAndDispatchCoordinationOp( + importerOf(counter), + "ref", + fakeStore(undefined), + dispatchInput, + ); + expect(outcome).toEqual({ kind: "ok", projection: { count: 1 } }); + }); + + it("should surface the plugin's rejection for an invalid op", async () => { + const outcome = await loadAndDispatchCoordinationOp( + importerOf(counter), + "ref", + fakeStore(undefined), + { ...dispatchInput, op: { kind: "bad" } }, + ); + expect(outcome).toEqual({ kind: "rejected", error: "bad_op" }); + }); + + it("should return plugin_unavailable when the plugin cannot be loaded", async () => { + const outcome = await loadAndDispatchCoordinationOp( + throwingImporter, + "ref", + fakeStore(undefined), + dispatchInput, + ); + expect(outcome).toEqual({ kind: "plugin_unavailable" }); + }); +}); + +describe("loadAndProjectCoordinationForTeam", () => { + const projectInput = { + tenantId: "tn1", + eventId: "e1", + teamId: "t1", + ctx, + fallbackProjection: { count: -1 }, + }; + + it("should project the loaded plugin's per-team view", async () => { + const out = await loadAndProjectCoordinationForTeam( + importerOf(counter), + "ref", + fakeStore({ state: { count: 5 }, version: 1 }), + projectInput, + ); + expect(out).toEqual({ count: 5 }); + }); + + it("should return the fallback projection when the plugin is unavailable", async () => { + const out = await loadAndProjectCoordinationForTeam( + throwingImporter, + "ref", + fakeStore(undefined), + projectInput, + ); + expect(out).toEqual({ count: -1 }); + }); +});