Files
UHD_Media_Tool/packages/mapping/src/slot.ts
T
rootandClaude Opus 5.5 1dd460d359 feat(db,contracts): W5-C subscription units migration 0025, contracts and mapping
- subscription: status pending (TMDB not ready: searchable/evaluable, not completable),
  last_searched_at / next_search_at
- content_unit (season): episode_total ledger with source / lock / timestamp
- acquisition_plan: unit_keys (Slot × unit reservation) and file_selection; drop the
  one-in-flight-per-slot unique index (checked under slot row lock per unit instead)
- new tables slot_deferral, slot_unit (per-unit quality ledger), subscription_history
- contracts: SubscriptionParams, SubscriptionHistory, SlotDeferral, SlotUnit, SubscriptionProgress,
  EpisodeTotalSet, CandidateEvaluation pri/trace/unitKeys and decision deferred;
  endpoints subscriptions.search/progress/setEpisodeTotal and subscription-history
- mapping: evaluation messages via describeViolation, pri/trace passthrough

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 23:33:56 +08:00

136 lines
4.6 KiB
TypeScript

import type {
AcquisitionSlot,
AcquisitionSlotCreate,
SlotStatus as ContractSlotStatus,
JsonObject,
} from "@umt/contracts";
import {
type SlotStatus as DomainSlotStatus,
describeViolation,
type SlotCompletionSpec,
type SlotEvaluation,
type SlotHardConditions,
type SlotSoftPreferences,
type SlotSpec,
type SlotUpgradePolicy,
} from "@umt/domain";
// Slot:状态两侧取值相同(REQ §6.1),映射表只为让任一侧增删值时编译/测试失败。
// 规则 JSON 列(hard_conditions / soft_preferences / upgrade_threshold / completion / fallback)↔ domain SlotSpec。
export type { DomainSlotStatus };
const TO_CONTRACT: Record<DomainSlotStatus, ContractSlotStatus> = {
empty: "empty",
reserved: "reserved",
downloading: "downloading",
verifying: "verifying",
satisfied: "satisfied",
attention: "attention",
upgrade_pending: "upgrade_pending",
};
const TO_DOMAIN: Record<ContractSlotStatus, DomainSlotStatus> = {
empty: "empty",
reserved: "reserved",
downloading: "downloading",
verifying: "verifying",
satisfied: "satisfied",
upgrade_pending: "upgrade_pending",
attention: "attention",
};
export const DOMAIN_SLOT_STATUSES = Object.keys(TO_CONTRACT) as DomainSlotStatus[];
export const toContractSlotStatus = (s: DomainSlotStatus): ContractSlotStatus => TO_CONTRACT[s];
export const toDomainSlotStatus = (s: ContractSlotStatus): DomainSlotStatus => TO_DOMAIN[s];
type SlotRuleRow = Pick<
AcquisitionSlot,
"id" | "name" | "hardConditions" | "softPreferences" | "upgradeThreshold" | "completion" | "fallback"
>;
const isEmpty = (o: JsonObject | null | undefined) => !o || Object.keys(o).length === 0;
/**
* acquisition_slot 行 → domain SlotSpec。JSON 列按 domain 类型原样解释(写入时由 slotSpecToColumns 保证形状);
* fallback.unknownPolicy 对应 SlotSpec.unknownPolicy。
*/
export function slotSpecFromRow(row: SlotRuleRow): SlotSpec {
const unknownPolicy = row.fallback.unknownPolicy as SlotSpec["unknownPolicy"] | undefined;
return {
id: row.id,
name: row.name,
hard: row.hardConditions as SlotHardConditions,
...(unknownPolicy !== undefined ? { unknownPolicy } : {}),
...(isEmpty(row.softPreferences) ? {} : { soft: row.softPreferences as SlotSoftPreferences }),
...(isEmpty(row.upgradeThreshold) ? {} : { upgrade: row.upgradeThreshold as unknown as SlotUpgradePolicy }),
...(isEmpty(row.completion) ? {} : { completion: row.completion as unknown as SlotCompletionSpec }),
};
}
/** domain SlotSpec → acquisition_slot 规则列(Create/Update 可直接展开) */
export function slotSpecToColumns(
spec: SlotSpec,
): Pick<
AcquisitionSlotCreate,
"name" | "hardConditions" | "softPreferences" | "upgradeThreshold" | "completion" | "fallback"
> {
const json = (v: unknown): JsonObject => (v ? (JSON.parse(JSON.stringify(v)) as JsonObject) : {});
return {
name: spec.name,
hardConditions: json(spec.hard),
softPreferences: json(spec.soft),
upgradeThreshold: json(spec.upgrade),
completion: json(spec.completion),
fallback: spec.unknownPolicy === undefined ? {} : json({ unknownPolicy: spec.unknownPolicy }),
};
}
export interface ContractSlotEvaluation {
decision: SlotEvaluation["decision"];
/** 可以用这个候选生成计划(accept 或 upgrade) */
acceptable: boolean;
/** 与 DownloadPlanOutput.rejected 同形:硬条件不满足 / 字段未知的原因 */
rejected: { code: string; message: string }[];
reasons: string[];
/** 优先级阶梯 pri(未配置阶梯为 100;拒绝/未知为 null) */
pri: number | null;
/** 可读说明(逐条违规 + 逐层阶梯) */
trace: string[];
}
/** domain 评估结果 → 能力输出/决策记录用的扁平表示 */
export function toContractSlotEvaluation(ev: SlotEvaluation): ContractSlotEvaluation {
const trace = [...(ev.trace ?? [])];
switch (ev.decision) {
case "accept":
case "upgrade":
return {
decision: ev.decision,
acceptable: true,
rejected: [],
reasons: [...ev.reasons],
pri: ev.pri ?? null,
trace,
};
case "reject":
return {
decision: "reject",
acceptable: false,
rejected: ev.violations.map((v) => ({ code: `${v.field}.${v.code}`, message: describeViolation(v) })),
reasons: [],
pri: ev.ladder?.pri ?? null,
trace,
};
case "unknown_field":
return {
decision: "unknown_field",
acceptable: false,
rejected: ev.missing.map((m) => ({ code: `unknown_field.${m}`, message: `字段未知,需要补充: ${m}` })),
reasons: [],
pri: null,
trace,
};
}
}