Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<unknown>;

/**
* value が {@link CoordinationPlugin} 契約 (initialState + 3 必須 hook) を満たすか構造判定する。
* tick は optional なので問わない。 動的 load した未知 module を dispatcher に渡す前の門番。
*/
export function isCoordinationPlugin(
value: unknown,
): value is CoordinationPlugin<unknown, unknown> {
if (typeof value !== "object" || value === null) return false;
const p = value as Record<string, unknown>;
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<CoordinationPlugin<unknown, unknown> | 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<unknown>,
): Promise<CoordinationDispatchOutcome | PluginUnavailable> {
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<unknown> {
const plugin = await loadCoordinationPlugin(importer, moduleRef);
if (!plugin) return input.fallbackProjection;
return projectCoordinationForTeam(store, plugin, input);
}
164 changes: 164 additions & 0 deletions infrastructure/test/problem-deploy/coordination-plugin-loader.test.ts
Original file line number Diff line number Diff line change
@@ -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<CounterState, CounterOp, { count: number }> = {
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<string, unknown>): 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 });
});
});
Loading