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:
co-authored by
Claude Opus 5
parent
37e6c519f7
commit
d6c359b205
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
-- BUG-699 / BUG-704 的一次性数据修补,搬成迁移,好让产品直接用现成的
|
||||
-- `Migrate Staging Database` 按钮跑,不必 SSH、不必拿库口令。
|
||||
--
|
||||
-- 两段 SQL 逐字来自 frontend/scripts/repair-rectification-session-titles.mjs
|
||||
-- (该脚本自此降级为只读核对工具):
|
||||
-- 1. 标题:把 `M月D日 · 生时校正` 里与 created_at 对不上的日期按
|
||||
-- Asia/Shanghai 的月日修回;取不到 created_at 就退回不带日期的
|
||||
-- 「生时校正」,不猜日期。不匹配该正则的手改标题一律不动。
|
||||
-- 2. 活跃时间:把历史冻结的 chat_sessions.updated_at 按最后一条 turn /
|
||||
-- last_activity_at 回填,`updated_at <` 是单调守卫,只前进不回拨。
|
||||
--
|
||||
-- 幂等:两段 where 在改完之后都不再匹配被改过的行——标题改完月日即与
|
||||
-- created_at 相等(created_at is null 的改成「生时校正」,连正则都不再匹配),
|
||||
-- updated_at 回填后不再小于目标值。重复应用影响 0 行。
|
||||
--
|
||||
-- 生产(停在 7b620c7a,没有 use-rectification-surface.ts,标题固定为
|
||||
-- 「生时校正」且不写库)上不存在被改坏的标题,两段 where 自然匹配 0 行,
|
||||
-- 是预期内的 no-op,不是失败。
|
||||
--
|
||||
-- 行数由 RAISE NOTICE 打出,产品在迁移日志里能直接看到修了多少行。
|
||||
|
||||
begin;
|
||||
|
||||
do $migration$
|
||||
begin
|
||||
if current_user <> 'schema_owner' then
|
||||
raise exception 'rectification_session_title_repair_requires_schema_owner'
|
||||
using errcode = '42501';
|
||||
end if;
|
||||
end
|
||||
$migration$;
|
||||
|
||||
do $repair$
|
||||
declare
|
||||
repaired_titles integer;
|
||||
refreshed_activity integer;
|
||||
begin
|
||||
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
|
||||
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
|
||||
)
|
||||
;
|
||||
get diagnostics repaired_titles = row_count;
|
||||
|
||||
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)
|
||||
;
|
||||
get diagnostics refreshed_activity = row_count;
|
||||
|
||||
raise notice 'rectification_session_title_repair repaired_titles=%', repaired_titles;
|
||||
raise notice 'rectification_session_title_repair refreshed_activity=%', refreshed_activity;
|
||||
end
|
||||
$repair$;
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
ACTIVITY_APPLY_SQL,
|
||||
TITLE_APPLY_SQL,
|
||||
TITLE_MATCH_SQL,
|
||||
} from "../scripts/repair-rectification-session-titles.mjs";
|
||||
|
||||
const filename = "20260916020000_rectification_session_title_repair.sql";
|
||||
const migration = readFileSync(
|
||||
new URL(`../supabase/migrations/${filename}`, import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Only trailing whitespace differs: the script builds `where ${TITLE_MATCH_SQL}`,
|
||||
// which leaves a space before the newline that no .sql file should carry.
|
||||
const normalize = (value: string) => value.replace(/[ \t]+$/gm, "").trim();
|
||||
|
||||
test("repair migration carries the checker's SQL verbatim", () => {
|
||||
for (const [name, sql] of [
|
||||
["TITLE_MATCH_SQL", TITLE_MATCH_SQL],
|
||||
["TITLE_APPLY_SQL", TITLE_APPLY_SQL],
|
||||
["ACTIVITY_APPLY_SQL", ACTIVITY_APPLY_SQL],
|
||||
] as const) {
|
||||
assert.ok(
|
||||
normalize(migration).includes(normalize(sql)),
|
||||
`${name} must be copied into ${filename} without being rewritten`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("repair migration keeps the guards that make it safe to re-run", () => {
|
||||
// Asia/Shanghai, not UTC: the wall-clock date is what the user reads (BUG-699).
|
||||
assert.match(migration, /at time zone 'Asia\/Shanghai'/);
|
||||
// Never guess a date when created_at is missing.
|
||||
assert.match(migration, /when created_at is null then '生时校正'/);
|
||||
// Monotonic: updated_at only moves forward (BUG-704).
|
||||
assert.match(
|
||||
migration,
|
||||
/session\.updated_at < coalesce\(turns\.last_turn_at, case_row\.last_activity_at\)/,
|
||||
);
|
||||
// Only schema_owner may run it, like every other business migration.
|
||||
assert.match(migration, /rectification_session_title_repair_requires_schema_owner/);
|
||||
assert.match(migration, /^begin;[\s\S]*^commit;$/m);
|
||||
});
|
||||
|
||||
test("repair migration reports its row counts so staging logs show a number", () => {
|
||||
assert.match(migration, /get diagnostics repaired_titles = row_count;/);
|
||||
assert.match(migration, /get diagnostics refreshed_activity = row_count;/);
|
||||
assert.match(migration, /raise notice '[^']*repaired_titles=%', repaired_titles;/);
|
||||
assert.match(migration, /raise notice '[^']*refreshed_activity=%', refreshed_activity;/);
|
||||
|
||||
// The counts only reach the log because the migrator forwards notices.
|
||||
const migrator = readFileSync(
|
||||
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(migrator, /client\.on\("notice"/);
|
||||
});
|
||||
|
||||
test("repair migration sorts last and is not copied into the identity foundation", () => {
|
||||
assert.ok(filename > "20260916010000_consultation_session_capacity.sql");
|
||||
assert.match(filename, /^\d{14}_[a-z0-9_]+\.sql$/);
|
||||
assert.equal(
|
||||
existsSync(fileURLToPath(new URL(`../db/migrations/${filename}`, import.meta.url))),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
});
|
||||
|
||||
test("the checker no longer writes; --apply points at the migration", () => {
|
||||
const checker = readFileSync(
|
||||
new URL("../scripts/repair-rectification-session-titles.mjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(checker, /--apply has been removed/);
|
||||
assert.match(checker, new RegExp(filename.replace(/\./g, "\\.")));
|
||||
// Importing it must not have opened a connection.
|
||||
assert.match(checker, /invokedPath === import\.meta\.url/);
|
||||
});
|
||||
Reference in New Issue
Block a user