fix(db): 把校正标题/活跃时间修补搬成迁移

BUG-699 / BUG-704 的数据修补原先只存在于
frontend/scripts/repair-rectification-session-titles.mjs,需要
SCHEMA_DATABASE_URL 才能跑,产品负责人没有任何按钮能执行它,所以那批
错日期的会话标题一直没修。

脚本里本来就是纯 SQL,搬进一次性迁移即可复用现成的
`Migrate Staging Database` 按钮:

- 新增 20260916020000_rectification_session_title_repair.sql,两段 update
  逐字取自脚本(合同测试比对,改了哪边都会红);权限守卫沿用
  20260915010000 的 schema_owner 写法。
- 幂等:改完之后两段 where 都不再匹配同一行,重复应用影响 0 行。
- 各自 get diagnostics + raise notice 打出行数;db-migrate.mjs 加 notice
  转发,否则 node-postgres 会把 NOTICE 丢掉,迁移日志里一个数字都看不到。
- 脚本降级为只读核对工具:--apply 改为报错并指向迁移;导入不再连库。

生产停在 7b620c7a(没有 use-rectification-surface.ts,标题固定且不写库),
两段 where 自然匹配 0 行,是预期内的 no-op。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-16 01:35:01 +00:00
co-authored by Claude Opus 5
parent 37e6c519f7
commit d6c359b205
4 changed files with 210 additions and 21 deletions
+7
View File
@@ -139,6 +139,13 @@ export async function runMigrations({
}) {
const files = await loadMigrationFiles(migrationsDirectories ?? migrationsDirectory);
const client = new Client({ connectionString });
// Data-repair migrations report how many rows they touched with RAISE NOTICE.
// node-postgres drops notices when nothing listens, which would leave the
// staging migration log with no row counts at all (BUG-699 / BUG-704 repair).
client.on("notice", (notice) => {
const message = typeof notice?.message === "string" ? notice.message.trim() : "";
if (message) logger.log(`notice ${message}`);
});
let locked = false;
try {
@@ -1,8 +1,23 @@
// 只读核对工具。**修补本身已由迁移承担**:
// 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;
const TITLE_MATCH_SQL = `
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 (
@@ -16,7 +31,7 @@ and (
const TITLE_COUNT_SQL = `select count(*)::int as n from public.chat_sessions where ${TITLE_MATCH_SQL}`;
const TITLE_APPLY_SQL = `
export const TITLE_APPLY_SQL = `
update public.chat_sessions
set title = case
when created_at is null then '生时校正'
@@ -43,7 +58,7 @@ where session.session_type = 'birth_time_rectification'
and session.updated_at < coalesce(turns.last_turn_at, case_row.last_activity_at)
`;
const ACTIVITY_APPLY_SQL = `
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
@@ -70,29 +85,34 @@ function connectionString() {
}
async function main() {
const apply = process.argv.includes("--apply");
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 titlesBefore = await client.query(TITLE_COUNT_SQL);
const activityBefore = await client.query(ACTIVITY_COUNT_SQL);
console.log(`mismatched_dated_rectification_titles=${titlesBefore.rows[0]?.n ?? 0}`);
console.log(`stale_rectification_updated_at=${activityBefore.rows[0]?.n ?? 0}`);
if (!apply) return;
const titlesUpdated = await client.query(TITLE_APPLY_SQL);
const activityUpdated = await client.query(ACTIVITY_APPLY_SQL);
const titlesAfter = await client.query(TITLE_COUNT_SQL);
const activityAfter = await client.query(ACTIVITY_COUNT_SQL);
console.log(`updated_title_rows=${titlesUpdated.rowCount ?? 0}`);
console.log(`updated_activity_rows=${activityUpdated.rowCount ?? 0}`);
console.log(`mismatched_after=${titlesAfter.rows[0]?.n ?? 0}`);
console.log(`stale_updated_at_after=${activityAfter.rows[0]?.n ?? 0}`);
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();
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
// 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;
});
}