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
84 lines
3.3 KiB
TypeScript
84 lines
3.3 KiB
TypeScript
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/);
|
|
});
|