fix(chat): make cloud the only truth for charts, synastry, and pin/archive
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Local fallbacks were creating fake saves and resurrecting deleted rows.
Pin and archive now live on chat_sessions so they follow the account.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-01 21:38:21 +08:00
co-authored by Cursor
parent 31c7b48261
commit ce6a8a7e72
16 changed files with 390 additions and 153 deletions
@@ -11,16 +11,20 @@ test("other chart saves do not require the owner's rectification state", () => {
assert.doesNotMatch(source, /saveOtherChart[\s\S]{0,500}missingProfileStep\(nextProfile\)/);
});
test("other chart save falls back to local library when cloud sync fails", () => {
assert.match(source, /let cloudSaved = false/);
assert.match(source, /record = await saveCloudChartProfile\(record\);[\s\S]{0,120}cloudSaved = true/);
assert.match(source, /catch\s*\{[\s\S]{0,300}已保存到本地星盘库;云端同步失败/);
assert.match(source, /localStorage\.setItem\(chartLibraryStorageKey\(accountId\), JSON\.stringify\(next\)\)/);
assert.match(source, /if \(cloudSaved\)[\s\S]{0,180}已保存到云端星盘库/);
assert.match(source, /async function deleteOtherChart[\s\S]{0,500}let cloudDeleted = false/);
test("other chart save fails closed when cloud sync fails", () => {
// Former values that locked the dual-truth fallback:
// `let cloudSaved = false`, catch copy "已保存到本地星盘库;云端同步失败",
// `localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next))`,
// delete copy "已从本地星盘库删除;云端同步失败". Cloud write failure is now failure.
assert.doesNotMatch(source, /let cloudSaved = false/);
assert.match(source, /await saveCloudChartProfile\(record\)/);
assert.match(source, /async function saveOtherChart[\s\S]{0,1600}catch \{[\s\S]{0,180}保存失败,请重试/);
assert.doesNotMatch(source, /localStorage\.setItem\(chartLibraryStorageKey/);
assert.doesNotMatch(source, /已保存到本地星盘库/);
assert.match(source, /已保存到云端星盘库/);
assert.match(source, /async function deleteOtherChart[\s\S]{0,500}await deleteCloudChartProfile\(recordId\)/);
assert.match(source, /async function deleteOtherChart[\s\S]{0,900}已从本地星盘库删除;云端同步失败/);
assert.doesNotMatch(source, /deleteOtherChart[\s\S]{0,500}return;\s*}\s*setChartLibrary/);
assert.match(source, /async function deleteOtherChart[\s\S]{0,700}catch \{[\s\S]{0,160}删除失败,请重试[\s\S]{0,80}return;/);
assert.doesNotMatch(source, /已从本地星盘库删除/);
});
test("adding another chart waits for the user to choose a relationship type", () => {
@@ -33,7 +37,7 @@ test("adding another chart waits for the user to choose a relationship type", ()
test("a successful cloud read replaces stale local other charts", () => {
assert.match(
source,
/fetchCloudChartLibrary\(\)[\s\S]{0,800}upsertSelfChart\(cloudLibrary\.filter\(\(record\) => record\.role !== "self"\), profileForLibrary\)/,
/fetchCloudChartLibrary\(\)[\s\S]{0,800}chartLibraryFromCloudOthers\(cloudLibrary, profileForLibrary, upsertSelfChart\)/,
);
assert.doesNotMatch(source, /fetchCloudChartLibrary\(\)[\s\S]{0,800}new Map\(\[[\s\S]{0,500}current\.filter\(\(record\) => record\.role === "other"\)/);
});
@@ -83,6 +87,15 @@ test("relationship intent selects domain-specific evidence instead of treating e
});
test("synastry history is replaced by the cloud list and save failures stay in page memory", () => {
// Former value: `new Map([...current, ...cloudHistory])` unioned local+cloud
// and `writeSynastryHistory(accountId, next)` on cloud save failure.
assert.doesNotMatch(source, /new Map\(\[\.\.\.current, \.\.\.cloudHistory\]/);
assert.match(source, /未能存入历史/);
assert.doesNotMatch(source, /writeSynastryHistory\(/);
assert.match(source, /discardLegacyCloudMirrorKeys\(/);
});
test("current chart selection is local and does not overwrite the owner's profile", () => {
assert.match(source, /activeChartStorageKey/);
assert.match(source, /localStorage\.setItem\(activeChartStorageKey\(accountId\), record\.id\)/);
+8 -3
View File
@@ -3,8 +3,8 @@ import test from "node:test";
import {
chartLibraryFromCloudOthers,
chartLibraryOnCloudFailure,
chartLibrarySessionBranch,
keepLocalChartLibraryOnCloudFailure,
} from "../src/lib/chart-library-session.ts";
type RecordShape = { id: string; role: "self" | "other" };
@@ -44,7 +44,12 @@ test("a successful cloud read keeps only non-self records before upserting self"
]);
});
test("a failed cloud read leaves the local library in place", () => {
test("a failed cloud read keeps only the profile-derived self chart", () => {
// Former value: keepLocalChartLibraryOnCloudFailure(local) === local.
// Local other-charts are no longer a fallback when the cloud read fails.
const local: RecordShape[] = [{ id: "self", role: "self" }, { id: "other-1", role: "other" }];
assert.equal(keepLocalChartLibraryOnCloudFailure(local), local);
assert.notDeepEqual(chartLibraryOnCloudFailure({ name: "self" }, upsertSelf), local);
assert.deepEqual(chartLibraryOnCloudFailure({ name: "self" }, upsertSelf), [
{ id: "self", role: "self" },
]);
});
@@ -62,6 +62,8 @@ test("assigns notice severity by message intent", () => {
assert.equal(noticeTone("重命名同步失败"), "error");
assert.equal(noticeTone("模型服务暂时不可用,当前无法发送问题。"), "error");
assert.equal(noticeTone("后台未找到本次咨询请求,已停止恢复,请重新发送。"), "error");
assert.equal(noticeTone("未能存入历史"), "error");
assert.equal(noticeTone("保存失败,请重试"), "error");
});
test("anchors the streaming scroll instead of following every token", () => {
+21 -2
View File
@@ -13,15 +13,17 @@ const sql = readFileSync(
const sendSource = page.slice(page.indexOf(" async function send("), page.indexOf("\n\n consultationReplay.current"));
test("session list GET omits messages while detail GET returns them", () => {
// Former list/detail column strings ended at updated_at; pinned and archived_at
// were added when those flags moved off localStorage.
assert.match(
listRoute,
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at"/,
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/,
);
assert.doesNotMatch(listRoute, /select\(SESSION_LIST_COLUMNS\)[\s\S]*messages/);
assert.match(itemRoute, /export async function GET/);
assert.match(
itemRoute,
/sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at"/,
/sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/,
);
assert.match(page, /async function fetchSessionDetail\(/);
assert.match(page, /async function ensureSessionMessages\(/);
@@ -58,3 +60,20 @@ test("PATCH compatibility accepts and ignores a legacy messages write", () => {
assert.match(itemRoute, /return NextResponse\.json\(\{ ok: true \}\)/);
assert.doesNotMatch(itemRoute, /\.update\(\{[\s\S]*messages:/);
});
test("pin and archive flags are session metadata, not localStorage", () => {
const pinSql = readFileSync(
new URL("../supabase/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url),
"utf8",
);
assert.match(pinSql, /add column if not exists pinned boolean not null default false/);
assert.match(pinSql, /add column if not exists archived_at timestamptz null/);
assert.match(pinSql, /grant update \(pinned, archived_at\)/);
assert.match(page, /function applyLegacySessionControls\(/);
assert.match(page, /writeChatSession\(sessionId, \{ pinned: nextPinned \}, "update"\)/);
assert.match(page, /writeChatSession\(sessionId, \{ archived_at: nextArchivedAt \}, "update"\)/);
assert.match(page, /discardLegacyCloudMirrorKeys\(/);
assert.doesNotMatch(page, /setPinnedSessionIds/);
assert.doesNotMatch(page, /localStorage\.setItem\(`\$\{prefix\}pinned`/);
assert.doesNotMatch(page, /writeSynastryHistory\(/);
});
+10 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { chatSessionCreateSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts";
import { chatSessionCreateSchema, chatSessionMetadataPatchSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts";
const sessionId = "11111111-1111-4111-8111-111111111111";
const values = {
@@ -111,6 +111,15 @@ test("chat session schema keeps structured thinking sections on assistant messag
assert.equal(parsed.messages[0]?.thinkingSections?.[0]?.heading, "统一参数与原始结构");
});
test("metadata patch accepts pin and archive fields without a transcript", () => {
assert.deepEqual(chatSessionMetadataPatchSchema.parse({ pinned: true }), { pinned: true });
assert.deepEqual(
chatSessionMetadataPatchSchema.parse({ archived_at: "2026-09-01T00:00:00.000Z" }),
{ archived_at: "2026-09-01T00:00:00.000Z" },
);
assert.deepEqual(chatSessionMetadataPatchSchema.parse({ archived_at: null }), { archived_at: null });
});
test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
await writeChatSession(sessionId, metadataPatch, "update", async (url, init) => {
+89 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
@@ -18,6 +18,10 @@ const acceptedExactFamilyMigration = readFileSync(
new URL("../supabase/migrations/20260816010000_accept_exact_family_birth_times.sql", import.meta.url),
"utf8",
);
const pinArchiveMigration = readFileSync(
new URL("../supabase/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url),
"utf8",
);
function rpcError(error: unknown): string {
if (!error || typeof error !== "object") return "";
@@ -87,6 +91,33 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.match(migration.stdout, /applied 20260824030000_rectification_turn_origin\.sql/);
assert.match(migration.stdout, /applied 20260831020000_feature_pricing_admin_runtime_read_policy\.sql/);
assert.match(migration.stdout, /applied 20260901010000_append_consultation_question\.sql/);
assert.match(migration.stdout, /applied 20260901020000_chat_session_pin_archive\.sql/);
assert.equal(
existsSync(fileURLToPath(new URL("../db/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url))),
false,
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
fixture.psql(pinArchiveMigration);
assert.equal(
fixture.psql(`
select is_nullable || ':' || data_type
from information_schema.columns
where table_schema = 'public'
and table_name = 'chat_sessions'
and column_name = 'pinned'
`),
"NO:boolean",
);
assert.equal(
fixture.psql(`
select is_nullable || ':' || data_type
from information_schema.columns
where table_schema = 'public'
and table_name = 'chat_sessions'
and column_name = 'archived_at'
`),
"YES:timestamp with time zone",
);
assert.equal(
fixture.psql(`
@@ -664,6 +695,63 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.equal(inserted.error, null);
assert.deepEqual(inserted.data, { id: sessionId });
const pinDefaults = await local
.from("chat_sessions")
.select("pinned,archived_at")
.eq("id", sessionId)
.single();
assert.equal(pinDefaults.error, null);
assert.deepEqual(pinDefaults.data, { pinned: false, archived_at: null });
const ownerPin = await local
.from("chat_sessions")
.update({ pinned: true, archived_at: "2026-09-01T00:00:00.000Z" })
.eq("id", sessionId)
.select("pinned,archived_at")
.single();
assert.equal(ownerPin.error, null);
assert.equal((ownerPin.data as { pinned: boolean }).pinned, true);
assert.ok((ownerPin.data as { archived_at: string | null }).archived_at);
fixture.psqlAs(
"identity_runtime",
"identity-runtime-test-password",
`
insert into identity.users (name, email, email_verified, email_verified_at)
values ('Pin Archive Other', 'pin-archive-other@example.com', true, now())
`,
);
const otherUserId = fixture.psql(
"select id from identity.users where email = 'pin-archive-other@example.com'",
);
const other = createLocalPostgresDataClient(
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
{ id: otherUserId, email: "pin-archive-other@example.com" },
);
const stolen = await other
.from("chat_sessions")
.update({ pinned: false, archived_at: null })
.eq("id", sessionId)
.select("id");
assert.equal((Array.isArray(stolen.data) ? stolen.data : []).length, 0);
assert.equal(
fixture.psql(`select pinned from public.chat_sessions where id = '${sessionId}'`),
"t",
);
assert.equal(
fixture.psql(`select archived_at is not null from public.chat_sessions where id = '${sessionId}'`),
"t",
);
const ownerRestore = await local
.from("chat_sessions")
.update({ pinned: false, archived_at: null })
.eq("id", sessionId)
.select("pinned,archived_at")
.single();
assert.equal(ownerRestore.error, null);
assert.deepEqual(ownerRestore.data, { pinned: false, archived_at: null });
const beforeTomorrow = await local
.from("chat_sessions")
.select("id")