/** * TaskAttachmentStore —— D-1 通用文件上传的**独立附件 store**(clay 拍 2026-07-27:不复用 fileSnapshot * 的 content-addressed blob 面,新建)。TiDB/PG 双方言 + local 文件店(local-task-attachment-store.ts)。 * * 为什么独立而不是骑 SqlBlobBackend/snapshot_blob:那套的引用追踪=「本域 manifest 里没有的即 orphan」, * 附件不在任何一域的 manifest 里 ⇒ 会被孤儿扫在 1h grace 后误收。生命周期也不同: * snapshot blob 跟 session 的工作树;**附件跟 task/session 的绑定**(上传先行、提交时绑定、E21 随会话 * 级联删、未绑定的按 TTL 收割)。 * * 语义要点: * - `owner` = auth principal ?? "default"(单库 tenant 轴;无 principal 部署全 default = 单用户语义)。 * 读/删门在 SQL WHERE 完成(filter-before-reduce,roster 语义条 1 同姿)——他人行连结果集都不进。 * - **字节本体在对象存储(clay 裁 2026-07-27:云形态对象存储必配,「现在就接」)**:SQL 只存 meta * (索引/owner 门/生命周期),bytes 走 {@link AttachmentBytesStore}(生产=既有 MinioBlobBackend, * 读侧内建 sha 校验)。对象键=**sha256**(同内容天然去重;跨 owner 共享对象 ⇒ 对象删除必须过 * 零引用检查)+ **附件专属 keyPrefix**(与快照 blob 命名空间隔离——同 sha 不同生命周期,混用会互删)。 * 删除次序:meta 先(E21 purge 永不被 MinIO 网络抖动卡死,blob-backend 异步删裁定同旨),对象删 * =零引用检查后 best-effort(失败只 log:无 meta 的对象经 API 不可达;彻底清收敛作 follow-up 记账)。 * ⚠️ 曾试过字节进 DB:真库连撞 max_allowed_packet(文本协议 2× 膨胀)与 TiDB 单 entry 6 MiB 限 * (txn-entry-size-limit)两道墙——分块版虽真库 5/5 跑通,被本裁定取代,教训留此。 * - 隔离键排序规则:TiDB 表级 utf8mb4_bin / PG owner 列 COLLATE "C"(bga F2 同姿,'=' 字节等价)。 * owner 值恒为 server 端从凭据推导(非用户输入),PAD SPACE 近撞面不可达,VARBINARY 键列不必要。 * - 布尔列:无(遵 1.287.0 归一约定,若未来加,PG 用 SMALLINT 0/1)。 * - 绑定:`bind` 把行钉到 sessionId(重复绑定=改绑,同 owner 内 last-wins;同 session 多 task 共享 * 自然成立——listBySession 是物化输入)。未绑定行 = 上传后从未被任何 task 引用 ⇒ reapUnbound(TTL)。 */ import type { Pool } from "mysql2/promise"; import type { PgQueryFn } from "./pg-query.js"; import { type IndexSpec } from "./ensure-index.js"; export interface TaskAttachmentRecord { id: string; owner: string; /** 消毒后的 basename(http 面用 core sanitizePathComponent 处理;store 只存不再消毒)。 */ name: string; mime: string; sha256: string; sizeBytes: number; createdAtMs: number; sessionId?: string; boundAtMs?: number; } export interface TaskAttachmentStore { put(rec: TaskAttachmentRecord & { content: Uint8Array; }): Promise; /** meta(不带 bytes);owner 不匹配/不存在 → null(无 oracle)。 */ get(owner: string, id: string): Promise; getContent(owner: string, id: string): Promise; /** 把 ids 钉到 sessionId(owner 门在 WHERE);返回真绑到的行数——调用方以 count===ids.length 判全成。 */ bind(owner: string, ids: string[], sessionId: string, boundAtMs: number): Promise; /** 物化输入(env 建立时)——按 session 单键查(绑定时已 owner 验过;session uuid 不可枚举)。 */ listBySession(sessionId: string): Promise; delete(owner: string, id: string): Promise; /** E21 会话删除级联(session_id 单键,路由已证会话所有权——resume-anchor 同理由)。 */ deleteBySession(sessionId: string): Promise; /** TTL 收割:未绑定且 created_at_ms < olderThanMs 的行。返回删除数。 */ reapUnbound(olderThanMs: number): Promise; /** D-1 孤儿**对象**彻底 GC(clay 拍 2026-07-28):列举对象存储 × meta 行对账,零行且过 grace 的对象删。 * 可选——local 店(meta+bytes 同盘同步删)无此孤儿类。 */ sweepOrphanObjects?(graceMs: number, nowMs?: number): Promise; } export declare const TASK_ATTACHMENT_TABLE = "task_attachment"; /** 字节面 seam —— MinioBlobBackend 的结构子集(hash 键控;读侧 sha 校验在实现内)。`listObjects` 可选 * (D-1 孤儿对象 GC 的列举面;测试桩/无列举后端缺席=sweep 诚实 0,不装作扫过)。 */ export interface AttachmentBytesStore { putBlob(hash: string, bytes: Uint8Array): Promise; getBlob(hash: string): Promise; deleteBlobs(hashes: string[]): Promise; listObjects?(): AsyncGenerator<{ hash: string; lastModifiedMs: number; }>; /** 删前复查面(复审 B-F1):返回对象当前 LastModified(缺席=已不在)。缺此面 ⇒ sweep 保守**不删** * (宁可留孤儿也不冒误删风险)——见 sweepOrphanObjects 的竞态注。 */ headObject?(hash: string): Promise<{ lastModifiedMs: number; } | undefined>; } /** 物化相对路径(确定性,notice 与真实写盘共用一份推导——两处各算一遍会漂):按 listBySession 序 * (created_at_ms, id)分配 `attachments/`;同名撞车时后者前缀 id 头 8 位。名字在上传时已 * 消毒为安全 basename([A-Za-z0-9._-]),拼接后仍是安全相对路径。 */ export declare function materializedRelPaths(atts: TaskAttachmentRecord[]): Map; /** 半场②(提交时绑定 + objective 告知)的可测核。fresh 腿硬验证(未知/他人 id → throw(mapTo400)); * resume 腿降级(warn+跳过——4xx 会砖存量 task;记录不可变 ⇒ 在场行的 notice 字节恒稳)。 * notice 路径从**绑定后的全 session 集**推导(与物化共用 materializedRelPaths),只罗列本次引用的文件。 */ export declare function bindAttachmentsForTask(opts: { store: TaskAttachmentStore; owner: string; ids: string[]; sessionId: string; leg: "fresh" | "resume"; nowMs: number; onMissing: (id: string) => void | never; }): Promise<{ notice: string | undefined; }>; /** 半场③(env 建立时物化)的可测核。**fail-loud**:objective 已宣告文件在场,静默缺文件=模型按幻影 * 文件行动,比 env 建立失败更糟 ⇒ 任一写失败即 throw。幂等:重建沙箱/resume 重写同路径同字节。 */ export declare function materializeAttachmentsInto(env: { createDir(path: string, options?: { recursive?: boolean; }): Promise<{ ok: boolean; error?: { message?: string; }; } | { ok: true; }>; writeFile(path: string, content: string | Uint8Array): Promise<{ ok: boolean; error?: { message?: string; }; } | { ok: true; }>; }, store: TaskAttachmentStore, sessionId: string): Promise; export declare function ensureTiDBTaskAttachmentSchema(pool: Pool): Promise; /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgTaskAttachmentSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */ export declare const TASK_ATTACHMENT_INDEXES: readonly IndexSpec[]; export declare function ensurePgTaskAttachmentSchema(q: PgQueryFn): Promise; /** bytes → 4 MiB 块序列(共享切块器,两方言 + 完整性校验共用)。 */ export declare class TiDBTaskAttachmentStore implements TaskAttachmentStore { private readonly pool; private readonly bytes; constructor(pool: Pool, bytes: AttachmentBytesStore); put(rec: TaskAttachmentRecord & { content: Uint8Array; }): Promise; get(owner: string, id: string): Promise; getContent(owner: string, id: string): Promise; /** 零引用对象删除(best-effort):meta 已删后,sha 仍被其他行引用则对象保留(跨行去重共享)。 * 失败只吞不抛——E21 purge 不被对象存储抖动卡死(blob-backend 异步删裁定同旨);无 meta 的对象 * 经 API 不可达,彻底清收敛记 follow-up。 */ private deleteBytesIfUnreferenced; bind(owner: string, ids: string[], sessionId: string, boundAtMs: number): Promise; listBySession(sessionId: string): Promise; delete(owner: string, id: string): Promise; deleteBySession(sessionId: string): Promise; reapUnbound(olderThanMs: number): Promise; /** D-1 孤儿对象彻底 GC(clay 拍 2026-07-28):对象存储列举 × meta 行对账。判据保守三层: * ①只删**真列举到**且 ②零 meta 行引用 且 ③LastModified 过 grace 的对象(上传先行窗口:对象先落、 * meta 行后写——grace 护住 put 竞态,宁漏勿误删,下轮再收);列举失败=整轮 throw(调用方 catch, * 「列不出来」绝不当「没有孤儿」)。 */ sweepOrphanObjects(graceMs: number, nowMs?: number): Promise; /** sweep 的引用对账半场:批内 sha 中仍有 meta 行的子集(单 IN 查询;DISTINCT 免重复行膨胀)。 */ private referencedShas; /** 删前复查(复审 B-F1):只保留「HEAD 仍在 且 LastModified 仍过 grace」的 hash。无 headObject * 面 ⇒ 返回空集(保守不删);HEAD 抛错 ⇒ 该对象本轮跳过(下轮再收)。 */ private filterStillOrphan; } export declare class PgTaskAttachmentStore implements TaskAttachmentStore { private readonly q; private readonly bytes; constructor(q: PgQueryFn, bytes: AttachmentBytesStore); put(rec: TaskAttachmentRecord & { content: Uint8Array; }): Promise; get(owner: string, id: string): Promise; getContent(owner: string, id: string): Promise; private deleteBytesIfUnreferenced; bind(owner: string, ids: string[], sessionId: string, boundAtMs: number): Promise; listBySession(sessionId: string): Promise; delete(owner: string, id: string): Promise; deleteBySession(sessionId: string): Promise; reapUnbound(olderThanMs: number): Promise; /** D-1 孤儿对象彻底 GC —— twin of the TiDB store(判据三层同注:真列举/零引用/过 grace)。 */ sweepOrphanObjects(graceMs: number, nowMs?: number): Promise; private referencedShas; /** 删前复查(复审 B-F1):只保留「HEAD 仍在 且 LastModified 仍过 grace」的 hash。无 headObject * 面 ⇒ 返回空集(保守不删);HEAD 抛错 ⇒ 该对象本轮跳过(下轮再收)。 */ private filterStillOrphan; } //# sourceMappingURL=task-attachment-store.d.ts.map