import { sql } from "@umt/db"; import type { Principal } from "../../http/auth.ts"; import { actorOfP, FilesError, type FmDeps, hashJson, jsonb } from "./common.ts"; import { invalidatePending } from "./list.ts"; // 计划冻结(artifact kind=plan, title=files_plan)、幂等键(artifact title=files_idem)、审计与 SSE。 export interface StoredPlan { id: string; content: C & { op: string; requestHash: string; actor: string }; contentHash: string; expiresAt: Date; } /** 冻结 dryRun 计划;content.request 与 organize 路由同形时,一次性审批(plan_type=organize_plan)可直接绑定 */ export async function savePlan( d: FmDeps, op: string, requestHash: string, principal: Principal | undefined, content: C, ): Promise<{ id: string; expiresAt: Date; contentHash: string }> { const expiresAt = new Date(d.ao.ports.now().getTime() + d.options.planTtlMs); const full = { ...content, op, requestHash, actor: actorOfP(principal) }; const contentHash = hashJson(full); const row = await d.ctx.db .insertInto("artifact") .values({ kind: "plan", subject_type: `files_${op}`, title: "files_plan", content: jsonb(full), content_hash: contentHash, expires_at: expiresAt, }) .returning("id") .executeTakeFirstOrThrow(); return { id: row.id, expiresAt, contentHash }; } /** 取执行用计划:不存在 / 过期 / 操作或请求不符 → 409 plan_stale */ export async function loadPlan(d: FmDeps, planId: string, op: string, requestHash: string): Promise> { const row = await d.ctx.db .selectFrom("artifact") .select(["id", "content", "content_hash", "expires_at", "title", "kind"]) .where("id", "=", planId) .executeTakeFirst(); if (row?.kind !== "plan" || row.title !== "files_plan") throw new FilesError("plan_stale", "planId 不存在,重新预览"); const content = row.content as StoredPlan["content"]; if (content.op !== op) throw new FilesError("plan_stale", `planId 属于 ${content.op} 操作,不能用于 ${op}`); if (!row.expires_at || row.expires_at.getTime() <= d.ao.ports.now().getTime()) throw new FilesError("plan_stale", "计划已过期,重新预览"); if (content.requestHash !== requestHash) throw new FilesError("plan_stale", "请求与预览时不一致,重新预览"); return { id: row.id, content, contentHash: row.content_hash, expiresAt: row.expires_at }; } /** 计划用完即作废(防重放执行) */ export async function retirePlan(d: FmDeps, planId: string): Promise { await d.ctx.db.updateTable("artifact").set({ expires_at: d.ao.ports.now() }).where("id", "=", planId).execute(); } /** API Key 的一次性审批:绑定该计划 id 与内容哈希,消费后标 consumed */ export async function consumeApproval(d: FmDeps, planId: string, contentHash: string): Promise { const r = await sql<{ id: string }>` update approval set status = 'consumed', updated_at = now() where id = ( select id from approval where plan_id = ${planId} and status = 'approved' and payload_hash = ${contentHash} and plan_type in ('files_plan', 'organize_plan') and (expires_at is null or expires_at > now()) order by created_at desc limit 1) and status = 'approved' returning id`.execute(d.ctx.db); return r.rows[0]?.id ?? null; } // ── 幂等 ── /** * 同一调用者 + 操作 + 幂等键:首次执行并存结果;重放返回首次结果; * 同键不同请求体 → 409;首次仍在执行 → 409(稍后重试)。执行失败不占键。 */ export async function idempotent( d: FmDeps, principal: Principal | undefined, op: string, key: string, request: unknown, fn: () => Promise, ): Promise { const db = d.ctx.db; const k = `${op}:${actorOfP(principal)}:${key}`; const reqHash = hashJson(request); const replay = async (): Promise => { const row = await db .selectFrom("artifact") .select(["content"]) .where("title", "=", "files_idem") .where(sql`content->>'key' = ${k}`) .executeTakeFirst(); if (!row) return undefined; const c = row.content as { reqHash: string; state: string; result?: T }; if (c.reqHash !== reqHash) throw new FilesError("exists", "idempotencyKey 已用于不同的请求"); if (c.state !== "done") throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试"); return c.result as T; }; const prev = await replay(); if (prev !== undefined) return prev; const ins = await sql<{ id: string }>` insert into artifact (kind, subject_type, title, content, content_hash, expires_at) values ('log', ${`files_${op}`}, 'files_idem', ${jsonb({ key: k, reqHash, state: "pending" })}::jsonb, ${hashJson(k)}, now() + interval '7 days') on conflict ((content->>'key')) where title = 'files_idem' do nothing returning id`.execute(db); const id = ins.rows[0]?.id; if (!id) { const again = await replay(); if (again !== undefined) return again; throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试"); } try { const result = await fn(); await db .updateTable("artifact") .set({ content: jsonb({ key: k, reqHash, state: "done", result }) }) .where("id", "=", id) .execute(); return result; } catch (err) { await db.deleteFrom("artifact").where("id", "=", id).execute(); throw err; } } // ── 审计 / 事件 ── export async function audit( d: FmDeps, principal: Principal | undefined, op: string, payload: Record, ): Promise { await d.ctx.db .insertInto("audit_event") .values({ actor_type: principal?.kind === "api_key" ? "api_key" : "user", actor_id: actorOfP(principal), action: `files.${op}`, target_type: "file", target_id: null, payload: jsonb(payload), }) .execute(); } /** SSE `files.changed`:受影响的根与目录(相对路径),前端据此刷新 */ export function filesChanged(d: FmDeps, rootId: string, dirs: string[]): void { invalidatePending(rootId); void d.ctx.events.publish("files.changed", { rootId, dirs: [...new Set(dirs)] }); }