feat(db,contracts): trash_item migration and file manager contract refinements

- 0024_trash: trash_item table + unique idempotency index on artifact(files_idem)
- files.ts: TrashItemRow, delete planId/planExpiresAt, organize group warnings,
  scrape returns refreshMetadata taskId
- registry tests list the new table/row schema

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:02:14 +08:00
1 parent 673cb3920a
commit 09f6e3a3e5
6 files changed
+98 -2

No files matched your search

+30 -2
View File
@@ -350,6 +350,8 @@ export const FilesOrganizeGroup = z.object({
truncated: z.boolean(),
recordId: Id.nullable(),
taskId: Id.nullable(),
/** 暂未生效的逐项覆写(season/episodes/slotId 等待 planImport 扩 fileOverrides) */
warnings: z.array(z.string()),
/** 该组失败原因(plan_stale / 授权 / H&R 等),其他组不受影响 */
error: z.object({ code: z.string(), message: z.string() }).nullable(),
});
@@ -376,6 +378,8 @@ export const FilesDeleteRequest = z.object({
unbind: z.boolean().default(false),
/** 永久删除二次确认:必须等于每项文件名(单项时) */
confirmName: z.string().max(255).optional(),
/** dryRun 返回的 planId;API Key 执行时必填(一次性审批绑定该计划),会话可省略 */
planId: Id.optional(),
});
export type FilesDeleteRequest = z.infer<typeof FilesDeleteRequest>;
@@ -396,6 +400,25 @@ export const FileDeleteCheck = z.object({
});
export type FileDeleteCheck = z.infer<typeof FileDeleteCheck>;
/** trash_item 表行(db-sync 对照用;API 用下方 TrashItem 投影) */
export const TrashItemRow = z.object({
id: Id,
rootId: z.string(),
libraryRootId: Id.nullable(),
rootHostPath: z.string(),
originalPath: z.string(),
trashPath: z.string(),
isDir: z.boolean(),
sizeBytes: NonNegInt,
actor: z.string().nullable(),
deletedAt: Timestamp,
expiresAt: Timestamp,
assetSnapshot: JsonObject.nullable(),
createdAt: Timestamp,
updatedAt: Timestamp,
});
export type TrashItemRow = z.infer<typeof TrashItemRow>;
export const TrashItem = z.object({
id: Id,
rootId: FileRootId,
@@ -414,6 +437,9 @@ export type TrashItem = z.infer<typeof TrashItem>;
export const FilesDeleteResult = z.object({
dryRun: z.boolean(),
/** dryRun 生成的删除计划(执行时回传;审批绑定) */
planId: Id.nullable(),
planExpiresAt: Timestamp.nullable(),
mode: z.enum(["trash", "permanent"]),
checks: z.array(FileDeleteCheck),
/** 执行后:进入回收站的项(permanent 为空) */
@@ -527,9 +553,11 @@ export const FilesScrapeRequest = z.object({
export type FilesScrapeRequest = z.infer<typeof FilesScrapeRequest>;
export const FilesScrapeResult = z.object({
mediaEntityId: Id,
changedFields: z.array(z.string()),
/** refreshMetadata 引擎 run id(异步;字段级刷新,用户锁优先,不动路径) */
taskId: z.string(),
/** 当前被用户锁定、刷新不会改动的字段 */
lockedFields: z.array(z.string()),
/** NFO/图片投影:W5 实现前恒为 not_implemented */
/** NFO/图片投影:W5 实现前请求时为 not_implemented */
nfo: z.enum(["written", "skipped", "not_requested", "not_implemented"]),
images: z.enum(["written", "skipped", "not_requested", "not_implemented"]),
});
+2
View File
@@ -93,6 +93,8 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
BrowserSession: { row: C.BrowserSession },
BrowserTab: { row: C.BrowserTab },
BrowserControlLease: { row: C.BrowserControlLease },
// W5-A 文件管理回收站(0024)
TrashItem: { row: C.TrashItemRow },
};
/** 引擎自有表(@umt/tasks 内部使用,不经 API 暴露),不要求 Row schema */
+2
View File
@@ -33,6 +33,7 @@ const STRING_CANDIDATES = [
"catalog.lookup",
"测试 Test",
"123456",
`lib:${UUID}`,
];
const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000];
@@ -88,6 +89,7 @@ const OVERRIDES: Record<string, (v: Record<string, unknown>) => Record<string, u
ExternalIdentityCreate: (v) => ({ ...v, contentUnitId: null }),
PathBindingCreate: (v) => ({ ...v, bindingKind: "season", seasonNumber: 1 }),
OrganizeRequest: (v) => ({ ...v, sourcePaths: undefined }),
FilesListQuery: (v) => ({ ...v, filter: "video,unidentified" }),
CatalogLookupInput: (v) => v,
IdentityResolveInput: (v) => v,
AssetLocationCreate: (v) => v,
+41
View File
@@ -0,0 +1,41 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl } from "../src/migration-helpers.ts";
// W5-A 文件管理(docs/plan/06-file-manager-spec.md §2.3.3):
// - trash_item:删除进回收站的记录(原根/原相对路径/回收站内路径/到期时间/关联资产快照);
// 还原或清除后删行(历史留在 audit_event)。root_id 为文件管理的根 id(lib:<uuid> / dl:<uuid>:<n>)。
// - artifact_files_idem_key:文件管理写操作的幂等键(artifact title='files_idem',content.key)唯一。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"trash_item",
`
root_id text not null,
library_root_id uuid references library_root (id) on delete set null,
root_host_path text not null,
original_path text not null,
trash_path text not null,
is_dir boolean not null default false,
size_bytes bigint not null default 0,
actor text,
deleted_at timestamptz not null default now(),
expires_at timestamptz not null,
asset_snapshot jsonb,
constraint trash_item_trash_path_key unique (trash_path)`,
)}
create index trash_item_expires_idx on trash_item (expires_at);
create index trash_item_root_idx on trash_item (root_id, deleted_at desc);
create unique index artifact_files_idem_key on artifact ((content->>'key')) where title = 'files_idem';
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, `drop index if exists artifact_files_idem_key;\n${dropTables(["trash_item"])}`);
}
+21
View File
@@ -1276,6 +1276,23 @@ export interface TitleAliasTable {
updated_at: GeneratedTimestamptz;
}
export interface TrashItemTable {
id: Generated<string>;
root_id: string;
library_root_id: string | null;
root_host_path: string;
original_path: string;
trash_path: string;
is_dir: Generated<boolean>;
size_bytes: Generated<number>;
actor: string | null;
deleted_at: GeneratedTimestamptz;
expires_at: Timestamptz;
asset_snapshot: Json | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface DB {
acquisition_plan: AcquisitionPlanTable;
acquisition_slot: AcquisitionSlotTable;
@@ -1338,6 +1355,7 @@ export interface DB {
tasks_op_ledger: TasksOpLedgerTable;
tasks_run_meta: TasksRunMetaTable;
title_alias: TitleAliasTable;
trash_item: TrashItemTable;
}
export type AcquisitionPlanRow = Selectable<AcquisitionPlanTable>;
@@ -1523,3 +1541,6 @@ export type TasksRunMetaUpdate = Updateable<TasksRunMetaTable>;
export type TitleAliasRow = Selectable<TitleAliasTable>;
export type NewTitleAlias = Insertable<TitleAliasTable>;
export type TitleAliasUpdate = Updateable<TitleAliasTable>;
export type TrashItemRow = Selectable<TrashItemTable>;
export type NewTrashItem = Insertable<TrashItemTable>;
export type TrashItemUpdate = Updateable<TrashItemTable>;
+2
View File
@@ -88,6 +88,8 @@ const EXPECTED_TABLES = [
"browser_session",
"browser_tab",
"browser_control_lease",
// W5-A 文件管理回收站(0024)
"trash_item",
].sort();
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */