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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:33:56 +08:00
1 parent d162d464c4
commit 1dd460d359
9 files changed
+405 -34

No files matched your search

@@ -0,0 +1,105 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// W5-C 订阅增强(docs/plan/05-subscription-gap.md §3):
// - subscription:pending 状态(TMDB 未就绪:可搜索/评估,不可完成);主动搜索的上次/下次时间;
// - content_unit(季):总集数账本 episode_total(只升;回退不低于已覆盖最高集;可人工锁定);
// - acquisition_plan:unit_keys 记录计划预留的内容单元(Slot×单元预留,等价 MP note);file_selection 记录季包选文件;
// 去掉"同 Slot 只允许一个在途计划"的唯一索引,改为服务端在 Slot 行锁内按单元不相交校验;
// - slot_deferral:Slot 忙(单元在途冲突 / 电影 Slot 在途)时到达的发布,计划结束后重评;
// - slot_unit:分集质量账本(Slot × 内容单元 → 当前资产 + 质量摘要 + 阶梯 pri),升级按单元替换;
// - subscription_history:订阅完成快照(完成后保留订阅行,可重新激活)。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table subscription drop constraint subscription_status_check;
alter table subscription add constraint subscription_status_check ${oneOf("status", ["active", "paused", "pending", "completed", "cancelled"])};
alter table subscription add column last_searched_at timestamptz;
alter table subscription add column next_search_at timestamptz;
create index subscription_next_search_idx on subscription (next_search_at) where next_search_at is not null;
alter table content_unit add column episode_total integer check (episode_total is null or episode_total >= 0);
alter table content_unit add column episode_total_source text ${oneOf("episode_total_source", ["tmdb", "injected", "manual", "covered_floor", "previous"])};
alter table content_unit add column episode_total_locked boolean not null default false;
alter table content_unit add column episode_total_at timestamptz;
alter table acquisition_plan add column unit_keys text[] not null default '{}'::text[];
alter table acquisition_plan add column file_selection jsonb;
drop index if exists acquisition_plan_slot_active_key;
create index acquisition_plan_slot_active_idx on acquisition_plan (slot_id) where status in ('reserved', 'submitted');
${entityTable(
"slot_deferral",
`
slot_id uuid not null references acquisition_slot (id) on delete cascade,
source_release_id uuid not null references source_release (id) on delete cascade,
reason text not null,
unit_keys text[] not null default '{}'::text[],
attempts integer not null default 1,
constraint slot_deferral_key unique (slot_id, source_release_id)`,
)}
${entityTable(
"slot_unit",
`
slot_id uuid not null references acquisition_slot (id) on delete cascade,
content_unit_id uuid not null references content_unit (id) on delete cascade,
media_asset_id uuid references media_asset (id) on delete set null,
plan_id uuid references acquisition_plan (id) on delete set null,
quality jsonb not null default '{}'::jsonb,
pri integer,
constraint slot_unit_key unique (slot_id, content_unit_id)`,
)}
create index slot_unit_asset_idx on slot_unit (media_asset_id) where media_asset_id is not null;
${entityTable(
"subscription_history",
`
subscription_id uuid references subscription (id) on delete set null,
media_entity_id uuid references media_entity (id) on delete set null,
season_unit_id uuid references content_unit (id) on delete set null,
media_kind text not null,
mode text not null,
reason text not null,
snapshot jsonb not null default '{}'::jsonb,
progress jsonb not null default '{}'::jsonb,
completed_at timestamptz not null default now()`,
)}
create index subscription_history_entity_idx on subscription_history (media_entity_id, completed_at desc);
create index subscription_history_subscription_idx on subscription_history (subscription_id);
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${dropTables(["subscription_history", "slot_unit", "slot_deferral"])}
drop index if exists acquisition_plan_slot_active_idx;
-- 回到"每 Slot 一个在途":同 Slot 多个在途时只保留最早的一个,其余取消
update acquisition_plan p set status = 'cancelled', status_reason = 'migration_down:multi_inflight'
where p.status in ('reserved', 'submitted')
and exists (select 1 from acquisition_plan q
where q.slot_id = p.slot_id and q.status in ('reserved', 'submitted')
and (q.created_at, q.id) < (p.created_at, p.id));
create unique index acquisition_plan_slot_active_key on acquisition_plan (slot_id) where status in ('reserved', 'submitted');
alter table acquisition_plan drop column if exists file_selection;
alter table acquisition_plan drop column if exists unit_keys;
alter table content_unit drop column if exists episode_total_at;
alter table content_unit drop column if exists episode_total_locked;
alter table content_unit drop column if exists episode_total_source;
alter table content_unit drop column if exists episode_total;
drop index if exists subscription_next_search_idx;
alter table subscription drop column if exists next_search_at;
alter table subscription drop column if exists last_searched_at;
update subscription set status = 'active' where status = 'pending';
alter table subscription drop constraint subscription_status_check;
alter table subscription add constraint subscription_status_check ${oneOf("status", ["active", "paused", "completed", "cancelled"])};
`,
);
}
+60 -1
View File
@@ -91,6 +91,7 @@ export type ContentUnitContentType =
| "unknown";
export type ContentUnitSegment = "full" | "upper" | "middle" | "lower" | "unknown";
export type ContentUnitStatus = "provisional" | "confirmed" | "retired";
export type ContentUnitEpisodeTotalSource = "tmdb" | "injected" | "manual" | "covered_floor" | "previous";
export type DecisionKind =
| "identity_ambiguous"
| "download_stalled"
@@ -209,7 +210,7 @@ export type SubscriptionMode = "once" | "continuous";
export type SubscriptionSeasonScope = "current_season" | "following_seasons";
export type SubscriptionOrigin = "user" | "agent" | "policy" | "import";
export type SubscriptionPriority = "immediate" | "tracking" | "new_work" | "upgrade";
export type SubscriptionStatus = "active" | "paused" | "completed" | "cancelled";
export type SubscriptionStatus = "active" | "paused" | "pending" | "completed" | "cancelled";
export type TaskStatus = "pending" | "running" | "waiting" | "succeeded" | "failed" | "cancelled" | "dead";
export type TasksOpLedgerStatus = "started" | "succeeded";
export type TitleAliasKind = "original" | "translated" | "short" | "site_title" | "romanized" | "other";
@@ -233,6 +234,8 @@ export interface AcquisitionPlanTable {
status_reason: string | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
unit_keys: Generated<string[]>;
file_selection: Json | null;
}
export interface AcquisitionSlotTable {
@@ -566,6 +569,10 @@ export interface ContentUnitTable {
field_locks: Generated<string[]>;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
episode_total: number | null;
episode_total_source: ContentUnitEpisodeTotalSource | null;
episode_total_locked: Generated<boolean>;
episode_total_at: Timestamptz | null;
}
export interface DecisionTable {
@@ -1143,6 +1150,29 @@ export interface SiteSnapshotTable {
updated_at: GeneratedTimestamptz;
}
export interface SlotDeferralTable {
id: Generated<string>;
slot_id: string;
source_release_id: string;
reason: string;
unit_keys: Generated<string[]>;
attempts: Generated<number>;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface SlotUnitTable {
id: Generated<string>;
slot_id: string;
content_unit_id: string;
media_asset_id: string | null;
plan_id: string | null;
quality: Generated<Json>;
pri: number | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface SourceReleaseTable {
id: Generated<string>;
site_id: string;
@@ -1201,6 +1231,23 @@ export interface SubscriptionTable {
created_by: string | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
last_searched_at: Timestamptz | null;
next_search_at: Timestamptz | null;
}
export interface SubscriptionHistoryTable {
id: Generated<string>;
subscription_id: string | null;
media_entity_id: string | null;
season_unit_id: string | null;
media_kind: string;
mode: string;
reason: string;
snapshot: Generated<Json>;
progress: Generated<Json>;
completed_at: GeneratedTimestamptz;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface TaskTable {
@@ -1347,9 +1394,12 @@ export interface DB {
site: SiteTable;
site_account: SiteAccountTable;
site_snapshot: SiteSnapshotTable;
slot_deferral: SlotDeferralTable;
slot_unit: SlotUnitTable;
source_release: SourceReleaseTable;
sse_event: SseEventTable;
subscription: SubscriptionTable;
subscription_history: SubscriptionHistoryTable;
task: TaskTable;
tasks_fact: TasksFactTable;
tasks_op_ledger: TasksOpLedgerTable;
@@ -1517,6 +1567,12 @@ export type SiteAccountUpdate = Updateable<SiteAccountTable>;
export type SiteSnapshotRow = Selectable<SiteSnapshotTable>;
export type NewSiteSnapshot = Insertable<SiteSnapshotTable>;
export type SiteSnapshotUpdate = Updateable<SiteSnapshotTable>;
export type SlotDeferralRow = Selectable<SlotDeferralTable>;
export type NewSlotDeferral = Insertable<SlotDeferralTable>;
export type SlotDeferralUpdate = Updateable<SlotDeferralTable>;
export type SlotUnitRow = Selectable<SlotUnitTable>;
export type NewSlotUnit = Insertable<SlotUnitTable>;
export type SlotUnitUpdate = Updateable<SlotUnitTable>;
export type SourceReleaseRow = Selectable<SourceReleaseTable>;
export type NewSourceRelease = Insertable<SourceReleaseTable>;
export type SourceReleaseUpdate = Updateable<SourceReleaseTable>;
@@ -1526,6 +1582,9 @@ export type SseEventUpdate = Updateable<SseEventTable>;
export type SubscriptionRow = Selectable<SubscriptionTable>;
export type NewSubscription = Insertable<SubscriptionTable>;
export type SubscriptionUpdate = Updateable<SubscriptionTable>;
export type SubscriptionHistoryRow = Selectable<SubscriptionHistoryTable>;
export type NewSubscriptionHistory = Insertable<SubscriptionHistoryTable>;
export type SubscriptionHistoryUpdate = Updateable<SubscriptionHistoryTable>;
export type TaskRow = Selectable<TaskTable>;
export type NewTask = Insertable<TaskTable>;
export type TaskUpdate = Updateable<TaskTable>;
+7 -1
View File
@@ -90,6 +90,10 @@ const EXPECTED_TABLES = [
"browser_control_lease",
// W5-A 文件管理回收站(0024)
"trash_item",
// W5-C 订阅增强(0025)
"slot_deferral",
"slot_unit",
"subscription_history",
].sort();
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
@@ -257,8 +261,10 @@ describe.skipIf(skip)("schema migrations (real PG)", () => {
idempotency_key: k,
});
await db.insertInto("acquisition_plan").values(plan("k1")).execute();
// 0025 起剧集 Slot 允许多个单元不相交的在途计划(服务端在 Slot 行锁内按单元校验);幂等键仍唯一
await db.insertInto("acquisition_plan").values(plan("k2")).execute();
await expect(db.insertInto("acquisition_plan").values(plan("k2")).execute()).rejects.toThrow(
/acquisition_plan_slot_active_key/,
/acquisition_plan_idempotency_key/,
);
});