fix(chat): make cloud the only truth for charts, synastry, and pin/archive
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:
@@ -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\)/);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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\(/);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user