// 媒体登记写入器(Plan 3 Task 4,spec §5.5/§11.1、前置事实 7/11、评审 Issue 1/3): // 消费适配器事件流的**双事件**面——attachment 行建资产(media_assets)、内容行提取引用 // (media_usages)——媒体域的领域 owner(Task 6 分派规则:content-writer 对 attachment 行 // 只留 source_records 足迹,media_assets 的唯一写入方在此)。 // // 三级对拍(前置事实 7 钉死,真实数据实证:媒体目录扁平 GUID 命名、与 WXR // attachment_url 零精确命中,唯一映射在旧 cms 库侧车): // ① uploads 相对路径清单精确命中:attachment_url 掐掉 // '…/wp-content/uploads/' 前缀 → manifest[path](清单 = media_bundle 快照 metadata. // manifest,{path, bytes, sha256}); // ② 侧车命中:manifest miss → sidecarMap[source_url] → filePath → 清单实体(精确路径 // miss 时按**文件名**候选——真实侧车 file_path 是扁平文件名而清单可含子目录;候选 // 多条 = 歧义,不自动选、全部列 issue、不插行,spec §5.5); // ③ missing:两级皆 miss → 只进报告(**不插 media_assets 行**——sha256 NOT NULL 无清单 // 实体可取,行插不进是库层防线不是巧合)。 // 命中行取值:public_path = 旧 uploads 路径(保旧 URL 语义)、sha256/byte_size 从清单、 // storage_path = bundle 副本相对路径 + 清单 path(绝对副本 = metadata.storageRoot + 此列)。 // // 魔数安全校验(spec §11.1):命中实体读存储副本头部实际字节(detectMediaType)—— // - SVG 检出(文本特征)→ status='quarantined':行存在(登记可审计)但不进产物语义, // 其引用不建 media_usages(usage 是发布面消费的引用关系,隔离资产不得经此进产物); // - 未知签名 → status='unsupported' + issue(不猜格式); // - 声明 MIME(清单/WXR 均无 MIME——声明侧由 URL 扩展名推断)与检测不符 → issue // (仍登记 ready:内容真实、扩展名说谎是数据质量问题不是安全风险)。 // // usage 时序(设计决定,WXR 文档序钉死):items 先于 attachment——引用先行、资产后到, // 故 usage 建立分两阶段:内容行到达时提取引用并落修订锚点(source_records 六列身份 → // entry_locales.current_revision_id——**依赖 content-writer 先处理同事件**,Task 6 编排 // 顺序契约),资产解析统一推迟到 finish() 对 media_assets 反查(id 反查 source_id / // URL 反查 source_url→public_path)——两种文档序终态一致。引用无对应资产且属本站媒体域 // (id 引用或 uploads 路径 URL)→ mediaDanglingRefs 计数 + issue(§7.6 禁静默);外域 // 热链 URL 不属本域对拍范围,不计悬空(无清单可比对,静默越过)。 // // 幂等:media_assets 唯一 (site, source_url) onConflictDoNothing(重跑零增行); // media_usages 复合主键 (asset, revision, field_path) onConflictDoNothing——field_path // 由提取序确定(featured/gallery 序号/正文 img 序号),重跑同流同值。事务边界:全部 // 单语句原子操作(ctx.db 直连约定,无跨语句不变量——与 term-writer 同纪律)。 import { open, readFile } from 'node:fs/promises' import { join } from 'node:path' import { and, eq } from 'drizzle-orm' import type { AdapterEvent } from '@sf/adapter-wp' import type { UniversalRow } from '@sf/contracts' import { entryLocales, mediaAssets, mediaUsages, sourceRecords } from '../../db/schema.js' import { isUniqueViolation, type MediaManifestEntry } from '../source/register.service.js' import { detectMediaType } from './magic-bytes.js' import type { ImportJobContext, ImportPipeline, ImportPipelineOutcome, WriterContext } from './import.service.js' const UPLOADS_MARKER = '/wp-content/uploads/' /** 未知签名的 detected_mime 落值(列 NOT NULL——不猜格式但要有确定值) */ const UNKNOWN_DETECTED_MIME = 'application/octet-stream' /** 魔数读取的头部字节(SVG 文本窗口 1KB + 余量) */ const HEAD_BYTES = 4096 /** URL 扩展名 → 声明 MIME(声明侧推断表——与 detectMediaType 的检出表独立) */ const EXT_MIME: Readonly> = Object.freeze({ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', pdf: 'application/pdf', svg: 'image/svg+xml', }) /** * 可分派的媒体写入器句柄(Task 6 编排形状,与 term/content 同构——但 finish 是异步: * usage 解析推迟到流终,见模块头"usage 时序")。句柄非可重入:一个句柄只服务一遍流。 */ export interface MediaEventHandler { /** 单事件分派(term/unsupported/error 为 no-op——无媒体信息) */ handleEvent(ev: AdapterEvent): Promise /** 流终:解析悬置引用 → media_usages / 悬空报告,取回累计产出(调用后句柄弃用) */ finish(): Promise } /** * 本写入器的 counts 键与归属(**counts 键是跨写入器的扁平全局命名空间**——import.service * 按"同名键**求和**"合并(Task 6 钉死——防覆盖安全网),各写入器必须各持独占键名,见 * ImportPipelineOutcome.counts 的键契约注记)。本写入器独占六键(media 前缀,**勿复用 * content-writer 的 'skipped'**): * - mediaRegistered:本运行**新插**的 media_assets 行数(含 quarantined/unsupported—— * 行已登记;幂等重跑为 0); * - mediaFromSidecar:其中经级②侧车解析的行数(mediaRegistered 的子集); * - mediaMissing:级③ missing 的 attachment 数(按事件计——重跑如实再现); * - mediaQuarantined:本运行检出 SVG 的 attachment 数(按事件计——重跑如实再现, * 与 mediaRegistered 在重跑时不再严格子集:行幂等而检出如实); * - mediaDanglingRefs:内容引用未解析到资产的条数(按引用计——重跑如实再现); * - mediaAmbiguous:多候选歧义的 attachment 数(按事件计,不插行)。 * issues:missing/歧义/隔离/未知签名/MIME 不符/悬空引用/副本读取失败/public_path 唯一键 * 冲突——全部显式报告(§7.6 禁静默)。 */ interface MediaWriterCounts { mediaRegistered: number mediaFromSidecar: number mediaMissing: number mediaQuarantined: number mediaDanglingRefs: number mediaAmbiguous: number } /** 悬置引用(内容行提取、流终解析):锚点 + 引用键两半 */ interface PendingRef { /** 引用方修订(content-writer 落的 current_revision_id——usage 的不可变锚) */ revisionId: string /** 修订数据域内的字段位(同流重跑稳定——复合主键的 field_path 侧) */ fieldPath: string kind: 'id' | 'url' /** id 引用 = attachment post id;URL 引用 = 引文原串 */ value: string /** URL 引用属本站 uploads 域时的公开路径形态(悬空裁决域);id 引用恒属本域 */ uploadsPublicPath: string | null rawLocation: string } /** 侧车 JSON 条目形状(register --media-map 已在登记期校验,此处只做读取防御) */ interface SidecarEntry { sourceUrl: string filePath: string } export function createMediaEventHandler(identity: WriterContext & { snapshotId: string /** 引用行 locale 校验基准(与 content-writer 同拒收口径——不双报) */ defaultLocale: string /** 存储副本绝对根(media_bundle 快照 metadata.storageRoot——登记期事实) */ storageRoot: string /** media_bundle 快照的 storagePath(相对 storageRoot;直传清单场景传 '') */ mediaBundleStoragePath: string /** 媒体清单(media_bundle metadata.manifest;缺席 = 空表 → 全部走级③) */ mediaManifest: MediaManifestEntry[] /** 侧车映射(manual_url_list:source_url → file_path) */ sidecarMap: Map }): MediaEventHandler { const { db, siteId } = identity const counts: MediaWriterCounts & Record = { mediaRegistered: 0, mediaFromSidecar: 0, mediaMissing: 0, mediaQuarantined: 0, mediaDanglingRefs: 0, mediaAmbiguous: 0, } const issues: string[] = [] const pendingRefs: PendingRef[] = [] // 清单索引:精确路径 + 文件名候选(级②的侧车 file_path 是扁平名,清单可含子目录) const manifestByPath = new Map() const manifestByBasename = new Map() for (const entry of identity.mediaManifest) { manifestByPath.set(entry.path, entry) const base = basenameOf(entry.path) const bucket = manifestByBasename.get(base) if (bucket === undefined) manifestByBasename.set(base, [entry]) else bucket.push(entry) } function basenameOf(path: string): string { const idx = path.lastIndexOf('/') return idx === -1 ? path : path.slice(idx + 1) } /** 扩展名推断声明 MIME(无扩展名/表外扩展 → null:列可空,无声明则无对拍) */ function declaredMimeOf(pathLike: string): string | null { const m = /\.([a-z0-9]+)$/i.exec(pathLike) if (m === null) return null const hit = EXT_MIME[m[1]!.toLowerCase()] return hit ?? null } /** * attachment_url → 旧 uploads 相对路径。无 uploads 标记的 URL(外站迁移源)级①无从 * 掐前缀:relPath=null(级①跳过),source/public 路径以 URL pathname 兜底。 * 注:不做百分号解码——清单 path 是字面文件名,编码差异按 miss 如实进报告。 */ function uploadsRelOf(fileUrl: string): { relPath: string | null; pathname: string } { const idx = fileUrl.indexOf(UPLOADS_MARKER) if (idx !== -1) { const rel = fileUrl.slice(idx + UPLOADS_MARKER.length) if (rel !== '') return { relPath: rel, pathname: fileUrl.slice(idx) } } try { return { relPath: null, pathname: new URL(fileUrl).pathname } } catch { // 非 URL 形态的原始串: pathname 兜底保留原文(可审计),级①不可用 return { relPath: null, pathname: fileUrl.startsWith('/') ? fileUrl : `/${fileUrl}` } } } /** 存储副本头部读取(魔数只需前若干字节——171MB 级文件不做全量读) */ async function readHead(abs: string): Promise { const handle = await open(abs, 'r') try { const buf = Buffer.alloc(HEAD_BYTES) const { bytesRead } = await handle.read(buf, 0, HEAD_BYTES, 0) return buf.subarray(0, bytesRead) } finally { await handle.close() } } /** * attachment 行主路径:三级解析 → 魔数 → 插 media_assets。 * locale 拒收口径与 content-writer 一致(该行已被 content-writer 拒收并记 issue, * 此处静默跳过不双报——attachment 行的媒体域处理同样以 locale 合法为前提)。 */ async function handleAttachmentRow(row: UniversalRow): Promise { if (row.locale !== identity.defaultLocale) return const fileUrl = typeof row.knownFields.attachmentUrl === 'string' ? row.knownFields.attachmentUrl : '' if (fileUrl === '') { counts.mediaMissing++ issues.push( `attachment 缺 attachment_url(${row.sourceType}/${row.sourceId},${row.rawLocation})——三级对拍无从下手,按 missing 报告`, ) return } const { relPath, pathname } = uploadsRelOf(fileUrl) // 级①:uploads 相对路径清单精确命中 let entry: MediaManifestEntry | null = relPath === null ? null : manifestByPath.get(relPath) ?? null let fromSidecar = false if (entry === null) { // 级②:侧车 source_url → file_path → 清单实体(精确路径 → 文件名候选) const filePath = identity.sidecarMap.get(fileUrl) if (filePath !== undefined) { const exact = manifestByPath.get(filePath) if (exact !== undefined) { entry = exact fromSidecar = true } else { const candidates = manifestByBasename.get(basenameOf(filePath)) ?? [] if (candidates.length > 1) { // 歧义不自动选(spec §5.5):全部候选列报告,不插行 counts.mediaAmbiguous++ issues.push( `媒体多候选歧义(${row.sourceType}/${row.sourceId},${fileUrl}):侧车 file_path=${filePath} 按文件名命中 ${candidates.length} 条清单实体——[${candidates.map((c) => c.path).join('、')}]——不自动选择,未登记`, ) return } if (candidates.length === 1) { entry = candidates[0]! fromSidecar = true } } } } if (entry === null) { // 级③:missing——只进报告不插行(sha256 NOT NULL:无清单实体插不进,库层防线) counts.mediaMissing++ issues.push( `媒体 missing(${row.sourceType}/${row.sourceId},${fileUrl}):清单与侧车均未命中——不插 media_assets 行(sha256 无来源)`, ) return } // 魔数:读存储副本实际字节(storageRoot + bundle 相对路径 + 清单 path) const storagePath = identity.mediaBundleStoragePath === '' ? entry.path : `${identity.mediaBundleStoragePath}/${entry.path}` let head: Buffer try { head = await readHead(join(identity.storageRoot, storagePath)) } catch (err) { issues.push( `媒体副本读取失败(${row.sourceType}/${row.sourceId},${fileUrl} → ${storagePath}):${err instanceof Error ? err.message : String(err)}——清单声称存在而副本缺席,未登记`, ) return } const detected = detectMediaType(head) const publicPath = relPath === null ? pathname : `${UPLOADS_MARKER}${relPath}` const declared = declaredMimeOf(publicPath) let status: 'ready' | 'quarantined' | 'unsupported' let detectedMime: string if (detected === null) { status = 'unsupported' detectedMime = UNKNOWN_DETECTED_MIME issues.push( `媒体未知签名(${row.sourceType}/${row.sourceId},${publicPath}):头部字节不匹配签名表——status=unsupported,不猜格式`, ) } else if (detected.svg) { status = 'quarantined' detectedMime = detected.type counts.mediaQuarantined++ issues.push( `SVG 检出已隔离(${row.sourceType}/${row.sourceId},${publicPath}):文本 0) { counts.mediaRegistered++ if (fromSidecar) counts.mediaFromSidecar++ } } catch (err) { // 唯一约束按约束名精确认领:定向 onConflictDoNothing 已消解 source_url 键,此处 // 23505 只可能是 public_path 唯一键——但仍校验约束名(未来加第三唯一索引时不误标)。 // postgres.js 的错误字段是 constraint_name(drizzle 包一层,真错在 cause) if ( isUniqueViolation(err) && (err as { cause?: { constraint_name?: unknown } }).cause?.constraint_name === 'media_assets_public_path_uq' ) { issues.push( `媒体 public_path 冲突(${row.sourceType}/${row.sourceId},${publicPath}):同站公开路径已被他行占用——未登记(不静默覆盖)`, ) return } throw err } } /** * 内容行引用提取(usage 两阶段的第一阶段):refs + bodyHtml img src → 悬置引用。 * featured/gallery 取 id 锚(WP meta 的 _thumbnail_id/_product_image_gallery);未知 * role 以 refs 原始下标落位(field_path 稳定优先于语义命名)。 */ function extractRefs(row: UniversalRow): Array> { const out: Array> = [] let galleryOrdinal = 0 row.attachmentRefs.forEach((ref, i) => { let fieldPath: string if (ref.role === 'featured') fieldPath = 'attachmentRefs.featured' else if (ref.role === 'gallery') fieldPath = `attachmentRefs.gallery[${galleryOrdinal++}]` else fieldPath = `attachmentRefs.raw[${i}]` if (ref.sourceId !== undefined && ref.sourceId !== '') { out.push({ fieldPath, kind: 'id', value: ref.sourceId, uploadsPublicPath: null, rawLocation: row.rawLocation }) } else if (ref.sourceUrl !== undefined && ref.sourceUrl !== '') { out.push({ fieldPath, kind: 'url', value: ref.sourceUrl, uploadsPublicPath: uploadsPublicPathOf(ref.sourceUrl), rawLocation: row.rawLocation, }) } // 双锚皆空:adapter 层已按非法 refs 记 issue(事件 issues 面),此处不重复报 }) const body = row.bodyHtml ?? '' if (body !== '') { const imgRe = /]*?\bsrc\s*=\s*["']([^"']+)["']/gi let m: RegExpExecArray | null let imgOrdinal = 0 while ((m = imgRe.exec(body)) !== null) { const src = m[1]! if (!src.startsWith('data:')) { // data: URI 是内联数据非资产引用;其余(绝对/相对/协议相对)统一进解析 out.push({ fieldPath: `data.bodyHtml.img[${imgOrdinal++}]`, kind: 'url', value: src, uploadsPublicPath: uploadsPublicPathOf(src), rawLocation: row.rawLocation, }) } } } return out } /** URL → 本站 uploads 公开路径形态(无标记 → null:外域热链不属悬空裁决域) */ function uploadsPublicPathOf(url: string): string | null { const idx = url.indexOf(UPLOADS_MARKER) return idx === -1 ? null : url.slice(idx) } /** 修订锚点:source_records 六列身份 → entry_locales.current_revision_id(content-writer 接缝) */ async function resolveRevisionAnchor(row: UniversalRow): Promise { const recs = await db .select({ contentEntryId: sourceRecords.contentEntryId }) .from(sourceRecords) .where( and( eq(sourceRecords.siteId, siteId), eq(sourceRecords.sourceConnectionId, identity.connectionId), eq(sourceRecords.sourceType, row.sourceType), eq(sourceRecords.sourceId, row.sourceId), eq(sourceRecords.locale, row.locale), eq(sourceRecords.sourceSnapshotId, identity.snapshotId), ), ) .limit(1) const entryId = recs[0]?.contentEntryId if (entryId === undefined || entryId === null) return null const els = await db .select({ currentRevisionId: entryLocales.currentRevisionId }) .from(entryLocales) .where( and( eq(entryLocales.siteId, siteId), eq(entryLocales.contentEntryId, entryId), eq(entryLocales.locale, row.locale), ), ) .limit(1) return els[0]?.currentRevisionId ?? null } /** 内容行(非 attachment):locale 口径对齐 content-writer(拒收行不双报),提取引用挂锚 */ async function handleContentRowRefs(row: UniversalRow): Promise { if (row.locale !== identity.defaultLocale) return const refs = extractRefs(row) if (refs.length === 0) return const anchor = await resolveRevisionAnchor(row) if (anchor === null) { issues.push( `媒体引用行无修订锚点(${row.sourceType}/${row.sourceId}/${row.locale},refs=${refs.length}):source_records/entry_locales 无 current 指针(content-writer 未处理该行?)——usage 跳过`, ) return } for (const ref of refs) pendingRefs.push({ ...ref, revisionId: anchor }) } /** 悬置引用 → 资产反查(id → source_id;URL → source_url 精确 → public_path 形态) */ async function lookupAsset( ref: PendingRef, ): Promise<{ id: string; sourceUrl: string; status: string } | null> { const columns = { id: mediaAssets.id, sourceUrl: mediaAssets.sourceUrl, status: mediaAssets.status, } if (ref.kind === 'id') { const rows = await db .select(columns) .from(mediaAssets) .where( and( eq(mediaAssets.siteId, siteId), eq(mediaAssets.sourceConnectionId, identity.connectionId), eq(mediaAssets.sourceId, ref.value), ), ) .limit(1) return rows[0] ?? null } const byUrl = await db .select(columns) .from(mediaAssets) .where(and(eq(mediaAssets.siteId, siteId), eq(mediaAssets.sourceUrl, ref.value))) .limit(1) if (byUrl.length > 0) return byUrl[0]! if (ref.uploadsPublicPath !== null) { const byPath = await db .select(columns) .from(mediaAssets) .where(and(eq(mediaAssets.siteId, siteId), eq(mediaAssets.publicPath, ref.uploadsPublicPath))) .limit(1) return byPath[0] ?? null } return null } return { async handleEvent(ev) { if (ev.kind !== 'row') return // term/unsupported/error:无媒体信息,本写入器不消费 if (ev.row.sourceType === 'wp:attachment') { await handleAttachmentRow(ev.row) return } await handleContentRowRefs(ev.row) }, /** 流终解析:悬置引用逐条对资产反查——命中且 ready → usage;无资产且属本域 → 悬空报告 */ async finish() { for (const ref of pendingRefs) { const asset = await lookupAsset(ref) if (asset === null) { if (ref.kind === 'id' || ref.uploadsPublicPath !== null) { counts.mediaDanglingRefs++ const target = ref.kind === 'id' ? `attachment ${ref.value}` : ref.value issues.push( `媒体引用悬空(${ref.rawLocation},${ref.fieldPath} → ${target}):无对应 attachment/清单记录——引用保留于报告,不静默丢弃`, ) } // 外域 URL(无 uploads 标记):站外热链不属本域对拍范围,静默越过(JSDoc 模块头) continue } if (asset.status !== 'ready') { // quarantined/unsupported 资产:不进产物语义——引用关系不建 usage(资产侧 // issue 已在登记时报告;引用命中非悬空) continue } await db .insert(mediaUsages) .values({ siteId, mediaAssetId: asset.id, contentRevisionId: ref.revisionId, fieldPath: ref.fieldPath, // id 引用无引文 URL——落资产登记的 source_url(审计可追);URL 引用落引文原串 sourceUrl: ref.kind === 'url' ? ref.value : asset.sourceUrl, }) .onConflictDoNothing() } return { counts, issues } }, } } /** * 从管道上下文构造媒体写入器句柄(Task 6 编排注入位):manifest/sidecar 从快照 metadata * 与存储副本解析(media_bundle.metadata.manifest / manual_url_list 副本 JSON)。 * bundle 缺席 = 空清单(三级全走 missing——诚实报告而非跳过);侧车缺席 = 空映射。 */ export async function createMediaEventHandlerFromContext( ctx: Pick< ImportJobContext, | 'db' | 'siteId' | 'connectionId' | 'defaultLocale' | 'wxrSnapshot' | 'mediaBundleSnapshot' | 'manualUrlListSnapshot' >, ): Promise { const bundle = ctx.mediaBundleSnapshot // manifest 形态守卫:bundle 在场而 manifest 非数组是登记期不变量破裂(bundle 缺席 // = 空清单是合法输入——三级全走 missing 如实报告),不与"合法缺席"共用静默空 coercion let manifest: MediaManifestEntry[] if (bundle === null) { manifest = [] } else if (Array.isArray(bundle.metadata.manifest)) { manifest = bundle.metadata.manifest as MediaManifestEntry[] } else { throw new Error('media_bundle 快照 metadata.manifest 形态异常(非数组)——登记期不变量破裂,拒绝静默按空清单处理') } let storageRoot = '' if (manifest.length > 0) { const raw = bundle?.metadata.storageRoot if (typeof raw !== 'string' || raw === '') { throw new Error('media_bundle 快照 metadata 缺 storageRoot(登记期事实缺席——无法读取存储副本做魔数校验)') } storageRoot = raw } return createMediaEventHandler({ db: ctx.db, siteId: ctx.siteId, connectionId: ctx.connectionId, snapshotId: ctx.wxrSnapshot.id, defaultLocale: ctx.defaultLocale, storageRoot, mediaBundleStoragePath: bundle?.storagePath ?? '', mediaManifest: manifest, // 侧车门控:空清单下级②不可达(侧车只能解析到清单实体)——跳过侧车加载,破损 // 侧车副本不因无用输入让整 job 失败 sidecarMap: manifest.length > 0 ? await loadSidecarMap(ctx) : new Map(), }) } /** 侧车加载:manual_url_list 副本 JSON → Map(source_url → file_path)。形状在登记期已校验 */ async function loadSidecarMap( ctx: Pick, ): Promise> { const snap = ctx.manualUrlListSnapshot if (snap === null) return new Map() const root = snap.metadata.storageRoot if (typeof root !== 'string' || root === '') { throw new Error('manual_url_list 快照 metadata 缺 storageRoot(登记期事实缺席——无法读取侧车副本)') } let parsed: unknown try { parsed = JSON.parse(await readFile(join(root, snap.storagePath), 'utf8')) } catch (err) { throw new Error( `侧车快照副本解析失败(${snap.storagePath}):${err instanceof Error ? err.message : String(err)}`, ) } if (!Array.isArray(parsed)) { throw new Error(`侧车快照副本形状异常(${snap.storagePath}):顶层不是数组`) } const map = new Map() for (const item of parsed) { const entry = item as Partial if ( typeof entry.sourceUrl !== 'string' || entry.sourceUrl === '' || typeof entry.filePath !== 'string' || entry.filePath === '' ) { throw new Error(`侧车快照副本形状异常(${snap.storagePath}):存在非 {sourceUrl, filePath} 条目`) } map.set(entry.sourceUrl, entry.filePath) } return map } /** * 媒体登记管道写入器({@link createMediaEventHandler} 的自消费整流便捷壳):单遍拉完 * ctx.events、逐事件分派、流终返回 outcome。与 content-writer 组合消费同流时须用 * {@link createMediaEventHandlerFromContext} 句柄形状(单消费方拉流分派,无 tee)。 */ export function createMediaWriter(): ImportPipeline { return async (ctx) => { const handler = await createMediaEventHandlerFromContext(ctx) for await (const ev of ctx.events) { await handler.handleEvent(ev) } return handler.finish() } }