feat(contracts): outbox event contract types
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// packages/contracts/src/__tests__/events.test.ts
|
||||
// 事件契约测试:合法事件编译通过、非法 type 编译期拒绝、OUTBOX_EVENT_TYPES
|
||||
// 与接口 union 1:1,以及 openapi Error.code enum 与 errors.ts ErrorCodes 1:1
|
||||
// (Task 4 审查遗留的枚举同步守护)。ts-jest 为全类型检查模式,@ts-expect-error
|
||||
// 与 Equal 断言在编译期生效。
|
||||
import { ErrorCodes, type ErrorCode } from '../errors.js';
|
||||
import type { components } from '../generated/openapi.js';
|
||||
import {
|
||||
OUTBOX_EVENT_TYPES,
|
||||
type OutboxEvent,
|
||||
type PhotoPublishedPayload,
|
||||
} from '../events.js';
|
||||
|
||||
// 类型恒等辅助:仅当 X 与 Y 是完全相同的类型(双向兼容,多/少一个成员都为 false)。
|
||||
type Equal<X, Y> =
|
||||
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
|
||||
? true
|
||||
: false;
|
||||
|
||||
// openapi.yaml 生成的 Error.code enum(src/generated/openapi.d.ts)。
|
||||
type ApiErrorCode = components['schemas']['Error']['code'];
|
||||
|
||||
const publishedEvent: OutboxEvent<PhotoPublishedPayload> = {
|
||||
eventId: 'cxxxxxxxxxxxxxxxxxxxxxx',
|
||||
type: 'photo.published',
|
||||
albumId: 'album-1',
|
||||
photoId: 'photo-1',
|
||||
entityVersion: 1,
|
||||
payload: {
|
||||
photoId: 'photo-1',
|
||||
albumId: 'album-1',
|
||||
objectKey: 'albums/album-1/photo-1.webp',
|
||||
thumbKey: 'thumbs/album-1/photo-1.webp',
|
||||
browseKey: 'browse/album-1/photo-1.webp',
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
publishedAt: '2026-09-17T08:00:00.000Z',
|
||||
entityVersion: 1,
|
||||
},
|
||||
occurredAt: '2026-09-17T08:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('OutboxEvent', () => {
|
||||
it('合法的 photo.published 事件通过类型检查且字段完整', () => {
|
||||
expect(publishedEvent.type).toBe('photo.published');
|
||||
expect(publishedEvent.payload).toMatchObject({
|
||||
photoId: 'photo-1',
|
||||
albumId: 'album-1',
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
});
|
||||
});
|
||||
|
||||
it('非法 type 在编译期被拒绝', () => {
|
||||
// @ts-expect-error 事件类型必须严格属于 §8.2 定义的 5 种之一
|
||||
const invalidType: OutboxEvent<PhotoPublishedPayload> = { ...publishedEvent, type: 'photo.uploaded' };
|
||||
expect(invalidType.type).toBe('photo.uploaded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OUTBOX_EVENT_TYPES', () => {
|
||||
it('恰好包含全部 5 个事件类型字符串', () => {
|
||||
expect([...OUTBOX_EVENT_TYPES]).toEqual([
|
||||
'photo.published',
|
||||
'photo.hidden',
|
||||
'photo.deleted',
|
||||
'photo.likes.updated',
|
||||
'album.updated',
|
||||
]);
|
||||
});
|
||||
|
||||
it('数组与接口 union 类型层 1:1(satisfies 管数组⊆union,Equal 管 union⊆数组)', () => {
|
||||
const arrayEqualsUnion: Equal<
|
||||
(typeof OUTBOX_EVENT_TYPES)[number],
|
||||
OutboxEvent['type']
|
||||
> = true;
|
||||
expect(arrayEqualsUnion).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openapi Error.code enum ↔ errors.ts ErrorCodes 同步', () => {
|
||||
it('两侧 code 联合类型双向恒等(1:1)', () => {
|
||||
const apiEqualsErrors: Equal<ApiErrorCode, ErrorCode> = true;
|
||||
const errorsEqualsApi: Equal<ErrorCode, ApiErrorCode> = true;
|
||||
expect(apiEqualsErrors && errorsEqualsApi).toBe(true);
|
||||
});
|
||||
|
||||
it('运行时兜底:ErrorCodes 值唯一且与 key 同名', () => {
|
||||
const entries = Object.entries(ErrorCodes);
|
||||
expect(new Set(entries.map(([, code]) => code)).size).toBe(entries.length);
|
||||
for (const [key, code] of entries) {
|
||||
expect(key).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// packages/contracts/src/events.ts
|
||||
// Outbox 事件契约(docs/03-技术实施方案.md §8.2):P1a 在业务事务内写 outbox,
|
||||
// P2 海外副本按 eventId 去重、按 entityVersion 单调递增消费。payload 由各事件
|
||||
// 类型自行约定,此处仅落 P2 展示一张照片所需的 photo.published 载荷(YAGNI)。
|
||||
export interface OutboxEvent<T = unknown> {
|
||||
eventId: string; // cuid,持久唯一
|
||||
type:
|
||||
| 'photo.published'
|
||||
| 'photo.hidden'
|
||||
| 'photo.deleted'
|
||||
| 'photo.likes.updated'
|
||||
| 'album.updated';
|
||||
albumId: string;
|
||||
photoId?: string;
|
||||
entityVersion: number; // 单调递增,P2 inbox 消费依据
|
||||
payload: T;
|
||||
occurredAt: string; // ISO UTC
|
||||
}
|
||||
|
||||
// photo.published 载荷:P2 海外副本展示一张照片所需的全部字段。
|
||||
export interface PhotoPublishedPayload {
|
||||
photoId: string;
|
||||
albumId: string;
|
||||
objectKey: string; // 原图对象键
|
||||
thumbKey: string; // 缩略图对象键
|
||||
browseKey: string; // 浏览图对象键
|
||||
width: number;
|
||||
height: number;
|
||||
publishedAt: string; // ISO UTC
|
||||
entityVersion: number;
|
||||
}
|
||||
|
||||
// 运行时校验用:全部合法事件类型字符串。satisfies 保证 数组 ⊆ 接口 union;
|
||||
// __tests__/events.test.ts 的 Equal 断言反向锁定 union ⊆ 数组,两侧永远 1:1。
|
||||
export const OUTBOX_EVENT_TYPES = [
|
||||
'photo.published',
|
||||
'photo.hidden',
|
||||
'photo.deleted',
|
||||
'photo.likes.updated',
|
||||
'album.updated',
|
||||
] as const satisfies readonly OutboxEvent['type'][];
|
||||
@@ -3,4 +3,5 @@
|
||||
// 消费方不跑 gen;本包改动后执行 pnpm gen 并提交生成文件 diff)。
|
||||
// NodeNext + ESM:相对导入必须带 .js 扩展名;生成文件仅含类型,用 export type * 在运行时擦除。
|
||||
export * from './errors.js';
|
||||
export * from './events.js';
|
||||
export type * from './generated/openapi.js';
|
||||
|
||||
Reference in New Issue
Block a user