// 只读核对工具。**修补本身已由迁移承担**: // frontend/supabase/migrations/20260916020000_rectification_session_title_repair.sql // 产品跑 Gitea → `Migrate Staging Database` 就会应用它,不需要 SSH 或库口令。 // // 本文件保留下来有两个用处: // 1. 排查时数一数还有多少行没对上(不写库,只 select count); // 2. 下面三段 SQL 是那条迁移的**唯一出处**,迁移逐字抄它们。 // tests/rectification-session-title-repair-migration.test.ts 会比对两边, // 改了这里而没同步迁移(或反过来)测试就红。 // // 需要 SCHEMA_DATABASE_URL 才能连库;没有它的环境只能跑上面那个合同测试。 import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; import pg from "pg"; const { Client } = pg; export const TITLE_MATCH_SQL = ` session_type = 'birth_time_rectification' and title ~ '^[0-9]{1,2}月[0-9]{1,2}日[[:space:]]*·[[:space:]]*生时校正([[:space:]]+[0-9]{2}:[0-9]{2})?$' and ( created_at is null or (substring(title from '^([0-9]{1,2})月'))::int is distinct from extract(month from created_at at time zone 'Asia/Shanghai')::int or (substring(title from '月([0-9]{1,2})日'))::int is distinct from extract(day from created_at at time zone 'Asia/Shanghai')::int ) `; const TITLE_COUNT_SQL = `select count(*)::int as n from public.chat_sessions where ${TITLE_MATCH_SQL}`; export const TITLE_APPLY_SQL = ` update public.chat_sessions set title = case when created_at is null then '生时校正' else to_char(created_at at time zone 'Asia/Shanghai', 'FMMM') || '月' || to_char(created_at at time zone 'Asia/Shanghai', 'FMDD') || '日 · 生时校正' end where ${TITLE_MATCH_SQL} `; const ACTIVITY_COUNT_SQL = ` select count(*)::int as n from public.chat_sessions as session join public.agentic_rectification_cases as case_row on case_row.session_id = session.id and case_row.user_id = session.user_id left join lateral ( select max(turn.created_at) as last_turn_at from public.agentic_rectification_turns as turn where turn.case_id = case_row.id ) as turns on true where session.session_type = 'birth_time_rectification' and session.updated_at < coalesce(turns.last_turn_at, case_row.last_activity_at) `; export const ACTIVITY_APPLY_SQL = ` update public.chat_sessions as session set updated_at = coalesce(turns.last_turn_at, case_row.last_activity_at) from public.agentic_rectification_cases as case_row left join lateral ( select max(turn.created_at) as last_turn_at from public.agentic_rectification_turns as turn where turn.case_id = case_row.id ) as turns on true where case_row.session_id = session.id and case_row.user_id = session.user_id and session.session_type = 'birth_time_rectification' and session.updated_at < coalesce(turns.last_turn_at, case_row.last_activity_at) `; function connectionString() { const value = process.env.SCHEMA_DATABASE_URL?.trim(); if (!value) { throw new Error("SCHEMA_DATABASE_URL is required"); } if (!/^postgres(ql)?:\/\//.test(value)) { throw new Error("SCHEMA_DATABASE_URL must be a PostgreSQL URL"); } return value; } async function main() { if (process.argv.includes("--apply")) { throw new Error( "--apply has been removed; the repair now ships as migration " + "20260916020000_rectification_session_title_repair.sql. " + "Apply it with Gitea -> Migrate Staging Database.", ); } const client = new Client({ connectionString: connectionString() }); await client.connect(); try { const titles = await client.query(TITLE_COUNT_SQL); const activity = await client.query(ACTIVITY_COUNT_SQL); console.log(`mismatched_dated_rectification_titles=${titles.rows[0]?.n ?? 0}`); console.log(`stale_rectification_updated_at=${activity.rows[0]?.n ?? 0}`); } finally { await client.end(); } } // Only connect when run as a command. Importing this file (the contract test // does) must not open a database connection. const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined; if (invokedPath === import.meta.url) { main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; }); }