feat: 生命周期广播事件与链式钩子设施(ADR-006)
- contracts:补齐生命周期广播事件(订阅、下载、整理结束、刮削、字幕、作品关注 / 元数据 / 季集结构、站点可达性) 的类型与 payload schema,LIFECYCLE_EVENTS 清单与 lifecycleKey 幂等键;HOOK_POINTS 链式钩子挂点; FEATURES 功能清单与 SLOT_POSITIONS 前端插槽位置 - outbox 中继支持一条事件路由给多个订阅方(其余订阅方用 <键>#<任务名>);生命周期事件允许多模块订阅, 没有订阅方时也被中继认领并镜像到 SSE - 服务端 HookRegistry:按注册顺序执行、rewrite / intercept 两类、单钩子超时与异常隔离、无注册零开销; ServerModule 增加 manifest 与 registerHooks,bootstrap 装配 ctx.hooks - ADR-006 与 server README「如何写一个功能模块」 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
0f656f0f58
commit
1db689d9a7
16 files changed
+1121
-22
No files matched your search
@@ -21,8 +21,11 @@ export interface OutboxRoute {
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/** 返回 null 表示该事件无需建任务,直接标记已投递 */
|
||||
export type OutboxRouteFn = (event: OutboxEvent) => OutboxRoute | null;
|
||||
/**
|
||||
* 返回 null 表示该事件无需建任务,直接标记已投递。
|
||||
* 返回数组 = 广播给多个订阅方(ADR-006):第一个任务用事件的幂等键,其余用 `<键>#<任务名>`,重投时由引擎幂等去重。
|
||||
*/
|
||||
export type OutboxRouteFn = (event: OutboxEvent) => OutboxRoute | readonly OutboxRoute[] | null;
|
||||
|
||||
export interface OutboxRelayOptions {
|
||||
db: AnyDb;
|
||||
@@ -82,11 +85,13 @@ export class OutboxRelay {
|
||||
for (const ev of rows.rows) {
|
||||
try {
|
||||
const routeFn = this.opts.routes[ev.type];
|
||||
const route = routeFn ? routeFn(ev) : null;
|
||||
if (route) {
|
||||
const payloadTrace = (ev.payload as { traceId?: unknown } | null)?.traceId;
|
||||
const routed = routeFn ? routeFn(ev) : null;
|
||||
const routes = (routed === null ? [] : Array.isArray(routed) ? routed : [routed]) as readonly OutboxRoute[];
|
||||
const baseKey = ev.idempotency_key ?? OutboxRelay.idempotencyKey(ev.id);
|
||||
const payloadTrace = (ev.payload as { traceId?: unknown } | null)?.traceId;
|
||||
for (const [i, route] of routes.entries()) {
|
||||
await this.opts.engine.enqueue(route.task, route.input, {
|
||||
idempotencyKey: ev.idempotency_key ?? OutboxRelay.idempotencyKey(ev.id),
|
||||
idempotencyKey: i === 0 ? baseKey : `${baseKey}#${route.task}`,
|
||||
traceId: route.traceId ?? (typeof payloadTrace === "string" ? payloadTrace : randomUUID()),
|
||||
...(route.priority === undefined ? {} : { priority: route.priority }),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { sql } from "kysely";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { type EnqueueOptions, OutboxRelay, type TaskEngine } from "../src/index.ts";
|
||||
import {
|
||||
connectDb,
|
||||
dropSchemas,
|
||||
migrateBizSchema,
|
||||
pgAvailable,
|
||||
resetSchemas,
|
||||
type TestDb,
|
||||
testSchemas,
|
||||
} from "./helpers.ts";
|
||||
|
||||
// 生命周期广播(ADR-006):一条 outbox 事件路由给多个订阅方任务。第一个任务用事件的幂等键,
|
||||
// 其余用 <键>#<任务名>;失败重投时已入队的由引擎幂等键去重,没入队的补上。
|
||||
|
||||
const schemas = testSchemas("outbox_bc");
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("OutboxRelay 广播给多个订阅方", () => {
|
||||
let db: TestDb;
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetSchemas(schemas);
|
||||
db = connectDb(schemas.biz);
|
||||
await migrateBizSchema(db, schemas.biz);
|
||||
}, 30_000);
|
||||
afterAll(async () => {
|
||||
await db?.destroy();
|
||||
await dropSchemas(schemas);
|
||||
}, 30_000);
|
||||
|
||||
it("每个订阅方各入队一次,幂等键按订阅方区分;中途失败重投时同键去重", async () => {
|
||||
await sql`INSERT INTO outbox_event (type, payload, idempotency_key)
|
||||
VALUES ('thing.done', '{"id":"t1"}'::jsonb, 'thing.done/t1')`.execute(db);
|
||||
const keys = new Map<string, number>();
|
||||
let failB = true;
|
||||
const engine: Pick<TaskEngine, "enqueue"> = {
|
||||
enqueue: async (task: string, _input: unknown, opts: EnqueueOptions) => {
|
||||
if (task === "b" && failB) {
|
||||
failB = false;
|
||||
throw new Error("引擎暂时不可用");
|
||||
}
|
||||
keys.set(opts.idempotencyKey, (keys.get(opts.idempotencyKey) ?? 0) + 1);
|
||||
return { runId: opts.idempotencyKey, deduplicated: false };
|
||||
},
|
||||
};
|
||||
const relay = new OutboxRelay({
|
||||
db,
|
||||
engine,
|
||||
routes: {
|
||||
"thing.done": (ev) => [
|
||||
{ task: "a", input: ev.payload },
|
||||
{ task: "b", input: ev.payload },
|
||||
],
|
||||
},
|
||||
});
|
||||
const first = await relay.pollOnce();
|
||||
expect(first).toEqual({ published: 0, failed: 1 });
|
||||
// 退避期过了再投:a 用同一把键再入一次(真实引擎按幂等键返回已有 run),b 补上
|
||||
await sql`UPDATE outbox_event SET next_attempt_at = now()`.execute(db);
|
||||
expect(await relay.pollOnce()).toEqual({ published: 1, failed: 0 });
|
||||
expect([...keys.keys()].sort()).toEqual(["thing.done/t1", "thing.done/t1#b"]);
|
||||
expect(keys.get("thing.done/t1")).toBe(2);
|
||||
expect(keys.get("thing.done/t1#b")).toBe(1);
|
||||
const row = await sql<{ status: string }>`SELECT status FROM outbox_event`.execute(db);
|
||||
expect(row.rows).toEqual([{ status: "dispatched" }]);
|
||||
});
|
||||
|
||||
it("单个订阅方保持原来的键;返回空数组 / null 直接标记已投递", async () => {
|
||||
await sql`DELETE FROM outbox_event`.execute(db);
|
||||
await sql`INSERT INTO outbox_event (type, payload, idempotency_key) VALUES
|
||||
('one.sub', '{}'::jsonb, 'k-one'), ('no.sub', '{}'::jsonb, 'k-none')`.execute(db);
|
||||
const keys: string[] = [];
|
||||
const relay = new OutboxRelay({
|
||||
db,
|
||||
engine: {
|
||||
enqueue: async (_t: string, _i: unknown, o: EnqueueOptions) => {
|
||||
keys.push(o.idempotencyKey);
|
||||
return { runId: "x", deduplicated: false };
|
||||
},
|
||||
},
|
||||
routes: { "one.sub": () => [{ task: "a", input: {} }], "no.sub": () => [] },
|
||||
});
|
||||
expect(await relay.pollOnce()).toEqual({ published: 2, failed: 0 });
|
||||
expect(keys).toEqual(["k-one"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user