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
parent 31c7b48261
commit ce6a8a7e72
16 changed files with 390 additions and 153 deletions
+16
View File
@@ -7150,3 +7150,19 @@
- 相关记录:BUG-464
- 复发自:无
- 修复版本:待发布
## BUG-466 | 星盘库、合盘历史和会话置顶/归档仍有一份会骗人的本地真相
- 状态:resolved
- 首次发现:2026-09-01
- 最近更新:2026-09-01
- 影响面:星盘库保存/删除、合盘历史、会话置顶与归档、`chat_sessions`
- 用户现象:云端保存失败仍提示已保存,刷新后记录消失;云端删除失败后刷新会复活;合盘历史按 id 并集,服务端删掉的记录会从本机回来;置顶和归档只活在这台浏览器里,换设备全部归零。
- 触发条件:断网或接口失败时保存/删除其他人的星盘;云端删除合盘历史后刷新;在一台设备置顶或归档后再换设备打开。
- 根因:星盘库和合盘历史同时写 localStorage 与云端,启动时又用云端替换或并集覆盖;置顶/归档只有 `jyotisha-session-controls` 本地键,没有账户数据。仓内曾用 `20260718100000_repair_missing_chart_profiles.sql``20260718101000_repair_missing_synastry_reports.sql` 修过同类双份真相。
- 修复:星盘库与合盘历史只信云端列表;写失败保留表单或当页报告并明确报错,不再写本地副本。启动清除旧库/历史键。`chat_sessions` 增加 `pinned``archived_at`,置顶/归档走既有 PATCH;本地控制键一次性导入后删除。每日星语缓存与 `activeChartId` 不动。
- 验证:合同测试锁保存/删除失败不再写本地、合盘不再并集、列表/详情带上两列、PATCH 元数据接受 `pinned`/`archived_at`、旧 `messages` PATCH 仍忽略。`npm run test:db` 重放加列迁移并验证 RLS。`./node_modules/.bin/tsc --noEmit`、前端测试与 `next build``/` `○ Static` 必须通过。
- 防复发:不得把 `jyotisha_chart_library` / `jyotisha_synastry_history` / `jyotisha-session-controls` 写回作为权威存储。云端写失败不得再提示已保存到本地。不得把业务迁移复制进 `frontend/db/migrations`。列表 GET 仍不得带回 `messages`
- 相关记录:BUG-464、BUG-465
- 复发自:无
- 修复版本:待发布
+1 -1
View File
@@ -14,7 +14,7 @@ import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
type RouteContext = { params: Promise<{ id: string }> };
const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at";
const 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";
const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function payloadHasMessages(payload: unknown): payload is { messages: unknown } {
+1 -1
View File
@@ -4,7 +4,7 @@ import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at";
const 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";
export async function GET() {
try {
+159 -123
View File
@@ -106,7 +106,11 @@ import {
membershipHref,
} from "@/lib/membership";
import { nextActivityView, activityCompletedTrail, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
import { chartLibrarySessionBranch } from "@/lib/chart-library-session";
import {
chartLibraryFromCloudOthers,
chartLibraryOnCloudFailure,
chartLibrarySessionBranch,
} from "@/lib/chart-library-session";
import {
emptyConsultationTimeline,
reduceConsultationTimeline,
@@ -249,6 +253,8 @@ type ChatSession = {
chartProfileId: string | null;
chartProfileName: string | null;
chartProfileRole: "self" | "other" | null;
pinned: boolean;
archivedAt: string | null;
messagesHydrated: boolean;
};
@@ -448,6 +454,8 @@ function createSession(
updatedAt: timestamp(),
sessionType,
rectificationCaseId: null,
pinned: false,
archivedAt: null,
messagesHydrated: true,
...chartBinding,
};
@@ -497,19 +505,62 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null {
};
}
function chartLibraryStorageKey(accountId: string) {
return `jyotisha_chart_library:${accountId}`;
}
function activeChartStorageKey(accountId: string) {
return `jyotisha_active_chart:${accountId}`;
}
function synastryHistoryStorageKey(accountId: string) {
return `jyotisha_synastry_history:${accountId}`;
}
function dailyStarlanguageStorageKey(accountId: string) {
return `jyotisha_daily_starlanguage:${accountId}`;
}
function sessionControlsStorageKey(accountId: string, kind: "pinned" | "archived") {
return `jyotisha-session-controls:${accountId}:${kind}`;
}
function discardLegacyCloudMirrorKeys(accountId: string) {
localStorage.removeItem(`jyotisha_chart_library:${accountId}`);
localStorage.removeItem(`jyotisha_synastry_history:${accountId}`);
}
function readLegacySessionControlIds(accountId: string, kind: "pinned" | "archived"): string[] {
try {
const parsed = JSON.parse(localStorage.getItem(sessionControlsStorageKey(accountId, kind)) || "null") as unknown;
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : [];
} catch {
return [];
}
}
function clearLegacySessionControlKeys(accountId: string) {
localStorage.removeItem(sessionControlsStorageKey(accountId, "pinned"));
localStorage.removeItem(sessionControlsStorageKey(accountId, "archived"));
}
function applyLegacySessionControls(accountId: string, sessions: ChatSession[]): ChatSession[] {
const pinnedKey = sessionControlsStorageKey(accountId, "pinned");
const archivedKey = sessionControlsStorageKey(accountId, "archived");
if (localStorage.getItem(pinnedKey) === null && localStorage.getItem(archivedKey) === null) {
return sessions;
}
const pinnedIds = new Set(readLegacySessionControlIds(accountId, "pinned"));
const archivedIds = new Set(readLegacySessionControlIds(accountId, "archived"));
const next = sessions.map((session) => ({
...session,
pinned: session.pinned || pinnedIds.has(session.id),
archivedAt: session.archivedAt || (archivedIds.has(session.id) ? new Date().toISOString() : null),
}));
void Promise.allSettled(next.flatMap((session, index) => {
const previous = sessions[index];
if (!previous) return [];
const patch: { pinned?: boolean; archived_at?: string | null } = {};
if (session.pinned !== previous.pinned) patch.pinned = session.pinned;
if (session.archivedAt !== previous.archivedAt) patch.archived_at = session.archivedAt;
if (patch.pinned === undefined && patch.archived_at === undefined) return [];
return [writeChatSession(session.id, patch, "update")];
}));
clearLegacySessionControlKeys(accountId);
return next;
}
type StoredDailyStarlanguage = {
readonly day: string;
readonly fingerprint: string;
@@ -574,34 +625,6 @@ function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) {
return [buildSelfChartRecord(profile), ...others];
}
function readChartLibrary(accountId: string): ChartLibraryRecord[] {
try {
const parsed = JSON.parse(localStorage.getItem(chartLibraryStorageKey(accountId)) || "[]") as ChartLibraryRecord[];
return Array.isArray(parsed)
? parsed
.filter((record) => record?.id && record?.profile)
.map((record) => {
const relationship = record.role === "self" ? "self" : record.relationship || record.profile.chartRelationship || "other";
return { ...record, profile: { ...record.profile, chartRelationship: relationship }, relationship };
})
: [];
} catch {
return [];
}
}
function readSynastryHistory(accountId: string): SynastryReportCard[] {
try {
const parsed = JSON.parse(localStorage.getItem(synastryHistoryStorageKey(accountId)) || "[]") as SynastryReportCard[];
return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.partnerName).slice(0, 10) : [];
} catch {
return [];
}
}
function writeSynastryHistory(accountId: string, history: SynastryReportCard[]) {
localStorage.setItem(synastryHistoryStorageKey(accountId), JSON.stringify(history.slice(0, 10)));
}
function normalizeSynastryReportApiRecord(record: SynastryReportApiRecord): SynastryReportCard | null {
if (!record.report || typeof record.report !== "object") return null;
return {
@@ -903,6 +926,7 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
chart_profile_role?: unknown;
session_type?: unknown;
updated_at?: unknown;
archived_at?: unknown;
};
const messagesPresent = Object.prototype.hasOwnProperty.call(session, "messages");
const messages: Message[] = Array.isArray(session.messages)
@@ -951,6 +975,12 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
chartProfileRole: session.chart_profile_role === "self" || session.chart_profile_role === "other"
? session.chart_profile_role
: null,
pinned: session.pinned === true,
archivedAt: typeof session.archived_at === "string" && session.archived_at
? session.archived_at
: typeof session.archivedAt === "string" && session.archivedAt
? session.archivedAt
: null,
updatedAt: typeof session.updatedAt === "number"
? session.updatedAt
: typeof session.updated_at === "string"
@@ -1286,8 +1316,6 @@ export default function Home() {
const [accountError, setAccountError] = useState("");
const [signingOut, setSigningOut] = useState(false);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [pinnedSessionIds, setPinnedSessionIds] = useState<string[]>([]);
const [archivedSessionIds, setArchivedSessionIds] = useState<string[]>([]);
const [showArchivedSessions, setShowArchivedSessions] = useState(false);
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
@@ -1391,12 +1419,12 @@ export default function Home() {
const rectificationSurfaceOpen = activeRectificationSession
&& activeSession.id === rectificationSessionId;
const visibleSessions = sessions
.filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id))
.filter((session) => showArchivedSessions ? Boolean(session.archivedAt) : !session.archivedAt)
.filter((session) => session.sessionType === "birth_time_rectification"
|| session.messages.length > 0
|| !session.messagesHydrated
|| session.id === activeSessionId)
.sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id)));
.sort((left, right) => Number(right.pinned) - Number(left.pinned));
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
const isLoading = pendingSessionId === activeSession?.id;
const productEntrypointsDisabled = !hydrated
@@ -1478,13 +1506,6 @@ export default function Home() {
rectificationSessionId,
]);
useEffect(() => {
if (!hydrated || !accountId) return;
const prefix = `jyotisha-session-controls:${accountId}:`;
setPinnedSessionIds(JSON.parse(localStorage.getItem(`${prefix}pinned`) || "[]"));
setArchivedSessionIds(JSON.parse(localStorage.getItem(`${prefix}archived`) || "[]"));
}, [accountId, hydrated]);
useEffect(() => {
if (!hydrated || !accountId) return;
void (async () => {
@@ -1499,13 +1520,6 @@ export default function Home() {
})();
}, [accountId, hydrated]);
useEffect(() => {
if (!hydrated || !accountId) return;
const prefix = `jyotisha-session-controls:${accountId}:`;
localStorage.setItem(`${prefix}pinned`, JSON.stringify(pinnedSessionIds));
localStorage.setItem(`${prefix}archived`, JSON.stringify(archivedSessionIds));
}, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]);
useEffect(() => {
if (!hydrated || !accountId) return;
setActiveChartId(localStorage.getItem(activeChartStorageKey(accountId)) || "self");
@@ -1523,37 +1537,47 @@ export default function Home() {
const profileForLibrary: Profile = activeChartId === "self" ? profile : account ? readProfile(account.profile) : profile;
if (branch === "hydrate-then-persist") {
chartLibraryLoadedAccount.current = accountId;
setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profileForLibrary));
setSynastryHistory(readSynastryHistory(accountId));
discardLegacyCloudMirrorKeys(accountId);
setChartLibrary(chartLibraryOnCloudFailure(profileForLibrary, upsertSelfChart));
setSynastryHistory([]);
void fetchCloudChartLibrary()
.then((cloudLibrary) => {
setChartLibrary(() => {
const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profileForLibrary);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart));
})
.catch(() => {
// Cloud chart library is best-effort; local library remains usable.
setComposerNotice("星盘库暂时无法读取,请重试。", {
label: "重试",
onClick: () => {
void fetchCloudChartLibrary()
.then((cloudLibrary) => {
setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart));
})
.catch(() => {
setComposerNotice("星盘库暂时无法读取,请重试。");
});
},
});
});
void fetchCloudSynastryHistory()
.then((cloudHistory) => {
setSynastryHistory((current) => {
const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const));
const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10);
writeSynastryHistory(accountId, next);
return next;
});
setSynastryHistory([...cloudHistory].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10));
})
.catch(() => {
// Cloud synastry history is best-effort; local history remains usable.
setComposerNotice("合盘历史暂时无法读取,请重试。", {
label: "重试",
onClick: () => {
void fetchCloudSynastryHistory()
.then((cloudHistory) => {
setSynastryHistory([...cloudHistory].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10));
})
.catch(() => {
setComposerNotice("合盘历史暂时无法读取,请重试。");
});
},
});
});
}
setChartLibrary((current) => {
const next = upsertSelfChart(current, profileForLibrary);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
setChartLibrary((current) => upsertSelfChart(current, profileForLibrary));
}, [account, accountId, activeChartId, profile]);
useEffect(() => {
@@ -1786,6 +1810,8 @@ export default function Home() {
chartProfileId: "self",
chartProfileName: previewProfile.name.trim() || "我",
chartProfileRole: "self",
pinned: false,
archivedAt: null,
messagesHydrated: true,
};
setAccount({
@@ -1857,6 +1883,7 @@ export default function Home() {
}
nextSessions = [initialSession];
}
nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions);
let reservedConsultation: ConsultationStatus | null = null;
const storedPending: StoredPendingConsultation | null = readStoredPendingConsultation(
@@ -2377,8 +2404,6 @@ export default function Home() {
const nextSessions = sessions.filter((item) => item.id !== session.id);
setSessions(nextSessions);
setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id));
setPinnedSessionIds((current) => current.filter((id) => id !== session.id));
setArchivedSessionIds((current) => current.filter((id) => id !== session.id));
if (activeSessionId === session.id) {
const fallbackId = nextSessions[0]?.id ?? "";
setActiveSessionId(fallbackId);
@@ -2395,18 +2420,37 @@ export default function Home() {
}
function togglePinnedSession(sessionId: string) {
setPinnedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]);
const session = sessions.find((item) => item.id === sessionId);
if (!session) return;
const nextPinned = !session.pinned;
updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned }));
void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => {
updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned }));
setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败");
});
}
function toggleArchivedSession(sessionId: string) {
const restoring = archivedSessionIds.includes(sessionId);
setArchivedSessionIds((current) => restoring ? current.filter((id) => id !== sessionId) : [sessionId, ...current]);
const session = sessions.find((item) => item.id === sessionId);
if (!session) return;
const restoring = Boolean(session.archivedAt);
const previousActiveId = activeSessionId;
const nextArchivedAt = restoring ? null : new Date().toISOString();
updateSession(sessionId, (current) => ({ ...current, archivedAt: nextArchivedAt }));
if (!restoring && activeSessionId === sessionId) {
const fallbackId = visibleSessions.find((session) => session.id !== sessionId)?.id ?? "";
const fallbackId = visibleSessions.find((item) => item.id !== sessionId)?.id ?? "";
setActiveSessionId(fallbackId);
if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace");
}
setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。");
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").catch((caught) => {
updateSession(sessionId, (current) => ({ ...current, archivedAt: session.archivedAt }));
if (!restoring && previousActiveId === sessionId) {
setActiveSessionId(previousActiveId);
if (!uiPreview.current) writeSessionUrl(previousActiveId || null, "replace");
}
setComposerNotice(caught instanceof Error ? caught.message : "归档同步失败");
});
}
async function shareSession(session: ChatSession) {
@@ -2680,43 +2724,32 @@ export default function Home() {
return;
}
if (!accountId) return;
let record: ChartLibraryRecord = {
const record: ChartLibraryRecord = {
id: editingChartId || globalThis.crypto.randomUUID(),
role: "other",
profile: nextProfile,
relationship: otherChartRelationship,
updatedAt: timestamp(),
};
let cloudSaved = false;
try {
if (editingChartId) {
record = await updateCloudChartProfile(record);
} else {
record = await saveCloudChartProfile(record);
cloudSaved = true;
}
cloudSaved = true;
} catch {
setProfileNotice("已保存到本地星盘库;云端同步失败,稍后会继续使用本地记录。");
const saved = editingChartId
? await updateCloudChartProfile(record)
: await saveCloudChartProfile(record);
const selfProfile = account ? readProfile(account.profile) : profile;
setChartLibrary((current) => {
const others = editingChartId
? current.map((item) => item.id === saved.id ? saved : item)
: [...current, saved];
return upsertSelfChart(others, selfProfile);
});
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setEditingChartId(null);
setAccountError("");
}
const selfProfile = account ? readProfile(account.profile) : profile;
setChartLibrary((current) => {
const others = editingChartId
? current.map((item) => item.id === record.id ? record : item)
: [...current, record];
const next = upsertSelfChart(others, selfProfile);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setEditingChartId(null);
setAccountError("");
if (cloudSaved) {
setProfileNotice(editingChartId ? "已更新其他人的星盘资料。" : "已保存到云端星盘库。请选择关系类型后点击“用于合盘”。");
} else {
setProfileNotice("保存到本地星盘库。");
} catch {
setProfileNotice("保存失败,请重试");
setAccountError("");
}
}
@@ -2731,12 +2764,12 @@ export default function Home() {
async function deleteOtherChart(recordId: string) {
if (!accountId || !window.confirm("确定删除这份其他人的星盘资料吗?删除后无法恢复。")) return;
let cloudDeleted = false;
try {
await deleteCloudChartProfile(recordId);
cloudDeleted = true;
} catch {
setProfileNotice("删除失败,请重试");
setAccountError("");
return;
}
setChartLibrary((current) => {
const next = current.filter((record) => record.id !== recordId || record.role === "self");
@@ -2744,13 +2777,10 @@ export default function Home() {
setActiveChartId("self");
localStorage.setItem(activeChartStorageKey(accountId), "self");
}
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
setAccountError("");
setProfileNotice(cloudDeleted
? "已从云端星盘库删除。"
: "已从本地星盘库删除;云端同步失败,稍后云端可能仍显示旧记录。");
setProfileNotice("已从云端星盘库删除。");
}
function makeDefaultChart(record: ChartLibraryRecord) {
@@ -3073,6 +3103,7 @@ export default function Home() {
// Merge the server-created session into the local list. The browser
// never generates a Case id; it only mirrors the returned binding.
const existing = sessions.find((session) => session.id === opened.sessionId);
const merged: ChatSession = {
id: opened.sessionId,
title: resolveSessionTitle("生时校正", undefined, {
@@ -3085,6 +3116,8 @@ export default function Home() {
updatedAt: timestamp(),
sessionType: "birth_time_rectification",
rectificationCaseId: opened.caseId,
pinned: existing?.pinned ?? false,
archivedAt: existing?.archivedAt ?? null,
messagesHydrated: true,
...chartSnapshotForSession(activeChartId, chartLibrary, profile),
};
@@ -3197,20 +3230,20 @@ export default function Home() {
createdAt: Date.now(),
};
let savedReportCard = reportCard;
let historyPersisted = !accountId;
if (accountId) {
try {
savedReportCard = await saveCloudSynastryReport(reportCard);
historyPersisted = true;
} catch {
// Local history remains the fallback when cloud persistence is unavailable.
historyPersisted = false;
}
}
setSynastryReportCard(savedReportCard);
if (accountId) {
setSynastryHistory((current) => {
const next = [savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10);
writeSynastryHistory(accountId, next);
return next;
});
if (accountId && historyPersisted) {
setSynastryHistory((current) => (
[savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10)
));
}
chooseSuggestedQuestion([
baseQuestion,
@@ -3218,6 +3251,9 @@ export default function Home() {
evidenceSummary,
payload.relationshipReport?.headline ? `结构化摘要:${payload.relationshipReport.headline}` : "",
].join("\n"), relationshipType === "business" ? "career" : "marriage");
if (accountId && !historyPersisted) {
setComposerNotice("未能存入历史");
}
} else {
chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage");
setComposerNotice(response.status === 404
@@ -4108,8 +4144,8 @@ export default function Home() {
const sidebarSessions = visibleSessions.map((session) => ({
id: session.id,
title: sessionSidebarTitle(session, chartLibrary),
pinned: pinnedSessionIds.includes(session.id),
archived: archivedSessionIds.includes(session.id),
pinned: session.pinned,
archived: Boolean(session.archivedAt),
}));
const sidebarCharts = (chartLibrary.length > 0
? chartLibrary
@@ -4354,7 +4390,7 @@ export default function Home() {
newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}
creatingSession={creatingSession}
sessionControls={{
archivedCount: archivedSessionIds.length,
archivedCount: sessions.filter((session) => session.archivedAt).length,
showingArchived: showArchivedSessions,
menuSessionId: sessionMenuId,
disabled: Boolean(pendingSessionId) || cancellationPending,
+8 -5
View File
@@ -9,14 +9,17 @@ export function chartLibrarySessionBranch(
return "persist-self";
}
export function chartLibraryFromCloudOthers<T extends { role: "self" | "other" }>(
export function chartLibraryFromCloudOthers<T extends { role: "self" | "other" }, P>(
cloudLibrary: readonly T[],
profile: unknown,
upsertSelfChart: (library: T[], profile: unknown) => T[],
profile: P,
upsertSelfChart: (library: T[], profile: P) => T[],
): T[] {
return upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile);
}
export function keepLocalChartLibraryOnCloudFailure<T>(localLibrary: T): T {
return localLibrary;
export function chartLibraryOnCloudFailure<T, P>(
profile: P,
upsertSelfChart: (library: T[], profile: P) => T[],
): T[] {
return upsertSelfChart([], profile);
}
+1 -1
View File
@@ -10,7 +10,7 @@ export type ChatNoticeAction = Readonly<{
}>;
const ongoingNotice = /正在|请稍候|请先|联网后/;
const failedNotice = /失败|无法|不可用|未找到|已写满|已被删除/;
const failedNotice = /失败|无法|不可用|未能|未找到|已写满|已被删除/;
const settledNotice = /^已|^回答已恢复/;
export function noticeTone(message: string): NoticeTone {
@@ -37,6 +37,8 @@ export const chatSessionMetadataPatchSchema = z.object({
title: z.string().trim().min(1).max(160).optional(),
theme: consultationDomainSchema.optional(),
model_id: z.string().trim().min(1).max(64).optional(),
pinned: z.boolean().optional(),
archived_at: z.string().datetime().nullable().optional(),
...chartBindingSchema,
}).strict().refine(
(value) => Object.values(value).some((field) => field !== undefined),
@@ -123,6 +125,8 @@ export function extractChatSessionMetadataPatch(payload: unknown): unknown {
if ("chart_profile_id" in record) patch.chart_profile_id = record.chart_profile_id;
if ("chart_profile_name" in record) patch.chart_profile_name = record.chart_profile_name;
if ("chart_profile_role" in record) patch.chart_profile_role = record.chart_profile_role;
if ("pinned" in record) patch.pinned = record.pinned;
if ("archived_at" in record) patch.archived_at = record.archived_at;
return patch;
}
@@ -130,6 +134,8 @@ export type ChatSessionMetadataPatch = Readonly<{
title?: string;
theme?: ConsultationDomain;
model_id?: string;
pinned?: boolean;
archived_at?: string | null;
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
@@ -0,0 +1,10 @@
begin;
alter table public.chat_sessions
add column if not exists pinned boolean not null default false,
add column if not exists archived_at timestamptz null;
grant update (pinned, archived_at)
on table public.chat_sessions to authenticated;
commit;
@@ -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")
+20
View File
@@ -1104,3 +1104,23 @@
- `./node_modules/.bin/tsc --noEmit` 通过。
- `npm test`**2424 通过 / fail=0 / skipped=0**(含数据库测试;本轮未改库)。
- 实测:无登录态,浏览器验收(刷新仍在 A、粘贴 `/?c=`、A→B→C 后退、删除死链、流式中切走再 back、401 回跳)未做,由合同测试覆盖。浅色/深色:新文案「该对话不存在或已被删除」走既有 sonner toast`已被删除` 命中 `failedNotice` 用 error 语气,颜色仍是 `--color-canvas` / `--color-ink`
## 2026-09-01 - TASK-cloud-truth-convergence:星盘库 / 合盘历史 / 置顶归档收归云端
- 星盘库启动只读 `fetchCloudChartLibrary`self 仍由 profile 重建),合盘历史用云端列表整体替换。`jyotisha_chart_library:*``jyotisha_synastry_history:*` 停读写,启动 `removeItem`。云端读失败给出重试,不再静默用本地副本。
- 保存/删除星盘:云端成功才改 state。失败保留表单或列表行,提示「保存失败,请重试」/「删除失败,请重试」,不再有 `cloudSaved` 或「已保存到本地」。合盘保存失败报告留在当页,提示「未能存入历史」,不写本地历史。
- `chat_sessions` 增加 `pinned boolean not null default false``archived_at timestamptz null`(只加列)。列表/详情 SELECT 带上这两列;置顶/归档走既有 PATCH,乐观更新,失败回滚。本地 `jyotisha-session-controls` 一次性导入后删键。每日星语缓存与 `activeChartId` 未动。
- 迁移额外 `grant update (pinned, archived_at)`:原表 update 是列级授权,不加新列 grant 则 PATCH 会失败。未改写既有 grant,也未复制进 `frontend/db/migrations`
- 断言例外(锁住的正是本轮要修的缺陷):
- `chart-library-other-profile.test.ts`:原值 `let cloudSaved = false`、catch 文案「已保存到本地星盘库;云端同步失败」、`localStorage.setItem(chartLibraryStorageKey…)`、删除「已从本地星盘库删除」。现为失败即失败。
- `chart-library-session.test.ts`:原值 `keepLocalChartLibraryOnCloudFailure(local) === local`。现为只保留 profile 重建的 self。
- `tests/test_supabase_user_data_contract.py`:原值页面必须含 `localStorage.setItem(chartLibraryStorageKey…)``chartLibraryStorageKey``synastryHistoryStorageKey``Cloud chart library is best-effort``writeSynastryHistory(accountId, next)`
- `chat-session-authority.test.ts`:列表/详情列字符串原在 `updated_at` 结束;现含 `pinned,archived_at`。列表仍不得带 `messages`
- `next build`:因本 worktree 的 `node_modules` 是指向其他 worktree 的符号链接,默认 Turbopack 拒绝;改用 `next build --webpack`。路由表仍是 `┌ ○ /`Static)。`/login` 仍是 `ƒ`
- 本地验证:
- `./node_modules/.bin/tsc --noEmit` 通过。
- 非数据库前端测试:**2393 通过 / fail=0 / skipped=0**。
- `npm run test:db`**34 通过 / fail=0 / skipped=0**。第二次整组为干净通过;第一次整组里 `database-admin-account-reset` 在 Docker 刚起来时 migration status=1,单测重跑即过。业务库测试 stdout 含 `applied 20260901020000_chat_session_pin_archive.sql`,幂等重放有 `column "pinned"/"archived_at" already exists, skipping`
- 合计 **2427 ≥ 2424**
- 实测:无登录态,未做浏览器里断网保存、云端删除后刷新、双 profile 置顶一致性、localStorage 键审计。由合同测试覆盖。浅色/深色:新文案「保存失败,请重试」「未能存入历史」「星盘库暂时无法读取,请重试。」走既有 sonner,`失败`/`未能`/`无法` 命中 `failedNotice` 用 error 语气,颜色仍是 `--color-canvas` / `--color-ink`
- 未提交、未推送。
+15 -5
View File
@@ -126,7 +126,9 @@ def test_chat_page_uses_authenticated_cloud_persistence() -> None:
assert "系统正在以账户记录为准同步点数" in source
assert "回答中途断开,已保留现有内容,本次已计费。" not in source
assert "本次已开始生成并计费" not in source
assert 'localStorage.setItem(chartLibraryStorageKey(accountId)' in source
# Former value: `localStorage.setItem(chartLibraryStorageKey(accountId)`
# wrote a local library copy on cloud save failure.
assert "localStorage.setItem(chartLibraryStorageKey(accountId)" not in source
assert 'localStorage.setItem("chat_sessions"' not in source
@@ -180,19 +182,24 @@ def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None:
"draftSynastryQuestionFromChart",
"synastryReportCard",
"synastryHistory",
"synastryHistoryStorageKey",
"discardLegacyCloudMirrorKeys",
"synastry-report-card",
"synastry-history-list",
'fetch("/api/synastry"',
"Ashtakoot",
"chartLibraryStorageKey",
"Cloud chart library is best-effort",
"jyotisha_chart_library",
"保存失败,请重试",
"星盘库",
"添加其他星盘",
"用于合盘",
"设为默认",
):
assert token in page
# Former values: synastryHistoryStorageKey, chartLibraryStorageKey,
# and the comment "Cloud chart library is best-effort".
assert "synastryHistoryStorageKey" not in page
assert "chartLibraryStorageKey" not in page
assert "Cloud chart library is best-effort" not in page
assert "ayanam-profile" not in page
assert "ayanam-sessions" not in page
@@ -251,11 +258,14 @@ def test_synastry_reports_are_cloud_persisted_per_user() -> None:
"fetchCloudSynastryHistory",
"saveCloudSynastryReport",
'fetch("/api/synastry-reports"',
"writeSynastryHistory(accountId, next)",
"未能存入历史",
"cloud_synastry_history_unavailable",
"cloud_synastry_report_save_failed",
):
assert token in page
# Former value: writeSynastryHistory(accountId, next) wrote a local
# history copy when cloud save failed.
assert "writeSynastryHistory(accountId, next)" not in page
def test_consultation_credit_lifecycle_is_idempotent_and_server_only() -> None: