拖动排序一次写完,新对话仍排在最上面(BUG-1449)。
Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
413c1fde12
commit
c6c69aa04b
@@ -19706,3 +19706,19 @@
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。
|
||||
|
||||
## BUG-1449 | 拖动排序一次发很多请求,新对话掉到组底
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-10-26
|
||||
- 最近更新:2026-10-26
|
||||
- 影响面:侧栏会话排序、星盘档案排序。
|
||||
- 用户现象:拖一次排序会按每一条各存一次。一组会话超过 40 条时,一部分存上、屏幕又退回去,刷新后的顺序不是刚拖的。档案也一样。拖过一次之后,新开的对话和刚活跃、还没拖过的对话排到组的底部。
|
||||
- 触发条件:在侧栏一组里拖动会话,或在星盘档案里拖动其他人。会话写入每分钟最多 40 次。
|
||||
- 根因:变了的每一行各发一个请求,没有放在同一次事务里。有序号的会话排在没序号的前面。
|
||||
- 修复:会话和档案各改成一个请求。数据库在一次事务里写完整组;任何一行不属于当前用户,整组都不改。档案仍只排其他人。没拖过的对话按最近活跃排在上面,拖过的按用户顺序排在下面。
|
||||
- 验证:`frontend/tests/order-batch.test.ts` 5 通过,0 失败。60 条会话一次请求。`frontend/tests/session-groups.test.ts` 与 `frontend/tests/people-archive-order.test.ts` 通过。`frontend/tests/database-order-batch.test.ts` 1 通过,0 失败:写入后重读一致;请求里夹一条别人的记录时,自己的和别人的都保持原顺序。
|
||||
- 防复发:上面的一次请求测试,以及真库的写入重读和整组回滚测试。
|
||||
- 相关记录:issue #111 验收 U6、U7。#105。#108。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。
|
||||
|
||||
|
||||
@@ -47,3 +47,14 @@
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 拉取与打勾 | `node node_modules/tsx/dist/cli.mjs --test tests/onboarding-facts-refresh.test.tsx` | 4 通过,0 失败,退出码 0 |
|
||||
|
||||
## U6 / U7 · 拖动排序一次写完,新对话仍在上面(BUG-1449)
|
||||
|
||||
会话和档案的排序各改成一个请求。数据库在一次事务里写完整组,任何一行不属于当前用户就整组不动。没拖过的对话按最近活跃排在上面,拖过的排在下面。
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 一次请求与新对话位置 | `node node_modules/tsx/dist/cli.mjs --test tests/order-batch.test.ts`;同命令再跑 `tests/session-groups.test.ts tests/people-archive-order.test.ts` | `order-batch` 5 通过,0 失败,退出码 0。排序与档案 16 项通过,0 失败 |
|
||||
| 真库写入后重读 | `node node_modules/tsx/dist/cli.mjs --test tests/database-order-batch.test.ts` | 1 通过,0 失败,退出码 0 |
|
||||
|
||||
断言三栏(`database-local-business.test.ts` 排序段):原值只核对两列存在,以及 authenticated 能更新 `archive_order`。新值多核对整组顺序的迁移已应用、不在 `frontend/db/migrations`,以及两个函数只给 authenticated 执行。原因是 U6 要一次事务写完整组,列的语义不改。
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` |
|
||||
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` |
|
||||
| `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
|
||||
| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U5 已提交) | `codex/ui-kit-onboarding-fix-20261026` |
|
||||
| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U6 已提交) | `codex/ui-kit-onboarding-fix-20261026` |
|
||||
| —(门禁修复,无新任务书) | `PROGRESS-birth-journey-contract-20261025.md` | **测试分支过不了门禁,网站停在完整数据版(二)**:BUG-1439 出生时间合同;BUG-1440 测试画布;BUG-1441 侧栏流式检查等第一次补画结束再数帧。页面不改 | 已合入 staging,等门禁 | `codex/gate-birth-journey-contract-20261025` |
|
||||
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md`;`PROGRESS-onboarding-4-motion-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022`。(四)T7 在 `codex/onboarding-4-motion-20261022` | (一)(二)(三)已在 staging;(四)本分支待验收 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
|
||||
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { orderBatchErrorStatus, parseOrderBatch } from "@/lib/order-batch";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
function rpcMessage(error: { message?: string } | null): string {
|
||||
return error?.message ?? "";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const parsed = parseOrderBatch(await request.json().catch(() => null));
|
||||
if (!parsed) return NextResponse.json({ error: "排序无效" }, { status: 400 });
|
||||
const { error } = await supabase.rpc("set_chart_profile_archive_order", {
|
||||
p_ids: parsed.ids,
|
||||
p_orders: parsed.orders,
|
||||
});
|
||||
if (error) {
|
||||
const status = orderBatchErrorStatus(rpcMessage(error));
|
||||
return NextResponse.json({ error: status === 400 ? "排序无效" : "排序没有保存" }, { status });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: "排序没有保存" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { orderBatchErrorStatus, parseOrderBatch } from "@/lib/order-batch";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
function rpcMessage(error: { message?: string } | null): string {
|
||||
return error?.message ?? "";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const limited = consumeUserRequestRateLimit("sessionWrite", user.id);
|
||||
if (!limited.ok) {
|
||||
return NextResponse.json({
|
||||
error: "请求过于频繁",
|
||||
code: "rate_limited",
|
||||
retryAfterSeconds: limited.retryAfterSeconds,
|
||||
}, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } });
|
||||
}
|
||||
const parsed = parseOrderBatch(await request.json().catch(() => null));
|
||||
if (!parsed) return NextResponse.json({ error: "排序无效" }, { status: 400 });
|
||||
const { error } = await supabase.rpc("set_chat_session_sidebar_order", {
|
||||
p_ids: parsed.ids,
|
||||
p_orders: parsed.orders,
|
||||
});
|
||||
if (error) {
|
||||
const status = orderBatchErrorStatus(rpcMessage(error));
|
||||
return NextResponse.json({ error: status === 400 ? "排序无效" : "排序没有保存" }, { status });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: "排序没有保存" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,8 @@ import {
|
||||
peopleRowMeta,
|
||||
peopleTimeRectified,
|
||||
} from "@/lib/people-archive-view";
|
||||
import { archiveOrdersForIds, sortByArchiveOrder } from "@/lib/people-archive-order";
|
||||
import { archiveOrderBatch, postOrderBatch } from "@/lib/order-batch";
|
||||
import { sortByArchiveOrder } from "@/lib/people-archive-order";
|
||||
import { usePeopleArchiveSortable } from "@/components/people/people-archive-sortable-loader";
|
||||
import { MAX_OTHER_SUBJECTS, MAX_SAVED_SUBJECTS } from "@/lib/subject-birth";
|
||||
import { profileGenderLabel } from "@/lib/profile-gender";
|
||||
@@ -350,29 +351,21 @@ export function PeoplePage() {
|
||||
}
|
||||
|
||||
async function persistArchiveOrder(orderedIds: readonly string[]) {
|
||||
const ids = orderedIds.filter((id) => id !== "self");
|
||||
const batch = archiveOrderBatch(orderedIds, people);
|
||||
if (!batch) return;
|
||||
const previous = people;
|
||||
const orders = archiveOrdersForIds(ids);
|
||||
const orders = new Map(batch.ids.map((id, index) => [id, batch.orders[index] ?? null]));
|
||||
setPeople((current) => {
|
||||
const selfRows = current.filter((person) => person.role === "self");
|
||||
const byId = new Map(current.filter((person) => person.role === "other").map((person) => [person.id, person]));
|
||||
const others = ids.flatMap((id) => {
|
||||
const others = batch.ids.flatMap((id) => {
|
||||
const person = byId.get(id);
|
||||
return person ? [{ ...person, archiveOrder: orders.get(id) ?? null }] : [];
|
||||
});
|
||||
return [...selfRows, ...others];
|
||||
});
|
||||
const changed = ids.filter((id) => (previous.find((person) => person.id === id)?.archiveOrder ?? null) !== (orders.get(id) ?? null));
|
||||
try {
|
||||
const saved = await Promise.all(changed.map(async (id) => {
|
||||
const response = await fetch(`/api/chart-profiles/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ archive_order: orders.get(id) ?? 0 }),
|
||||
});
|
||||
return response.ok;
|
||||
}));
|
||||
if (saved.some((ok) => !ok)) throw new Error("order");
|
||||
await postOrderBatch("/api/chart-profiles/order", batch);
|
||||
} catch {
|
||||
setPeople(previous);
|
||||
notifyError("排序没有保存");
|
||||
|
||||
@@ -17,6 +17,7 @@ import { mergeSubjectSessionRows } from "@/lib/subject-session-merge";
|
||||
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
|
||||
import { fallbackSessionId } from "@/lib/rectification-session-composer-guard";
|
||||
import { takeSynastryChatDraft } from "@/lib/synastry-report-chat-client";
|
||||
import { postOrderBatch, sidebarOrderBatch } from "@/lib/order-batch";
|
||||
import { sidebarOrdersForIds, sortSessions } from "@/lib/session-groups";
|
||||
import {
|
||||
clearBirthTimeConsultationConsent,
|
||||
@@ -310,30 +311,24 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
|
||||
function reorderSessions(orderedIds: readonly string[]) {
|
||||
if (uiPreview.current || orderedIds.length === 0) return;
|
||||
const sessions = sessionsRef.current;
|
||||
const orders = sidebarOrdersForIds(orderedIds);
|
||||
const previous = new Map<string, number | null>();
|
||||
const changed: string[] = [];
|
||||
for (const session of sessionsRef.current) {
|
||||
const next = orders.get(session.id);
|
||||
if (next === undefined || (session.sidebarOrder ?? null) === next) continue;
|
||||
previous.set(session.id, session.sidebarOrder ?? null);
|
||||
if (isUnsavedEmptyConsultation(session) || !cloudCreatedIds.current.has(session.id)) continue;
|
||||
changed.push(session.id);
|
||||
}
|
||||
const batch = sidebarOrderBatch(orderedIds, sessions, (session) => (
|
||||
!isUnsavedEmptyConsultation(session) && cloudCreatedIds.current.has(session.id)
|
||||
));
|
||||
const previous = new Map(sessions.map((session) => [session.id, session.sidebarOrder ?? null]));
|
||||
setSessions((list) => list.map((session) => {
|
||||
const next = orders.get(session.id);
|
||||
if (next === undefined || (session.sidebarOrder ?? null) === next) return session;
|
||||
return { ...session, sidebarOrder: next };
|
||||
}));
|
||||
if (changed.length === 0) return;
|
||||
void Promise.allSettled(changed.map((id) => writeChatSession(id, { sidebar_order: orders.get(id) ?? 0 }, "update"))).then((results) => {
|
||||
const failed = changed.filter((_, index) => results[index]?.status === "rejected");
|
||||
if (failed.length === 0) return;
|
||||
setSessions((list) => list.map((session) => (
|
||||
failed.includes(session.id)
|
||||
? { ...session, sidebarOrder: previous.get(session.id) ?? null }
|
||||
: session
|
||||
)));
|
||||
if (!batch) return;
|
||||
void postOrderBatch("/api/sessions/order", batch).catch(() => {
|
||||
setSessions((list) => list.map((session) => {
|
||||
const prior = previous.get(session.id);
|
||||
if (prior === undefined || (session.sidebarOrder ?? null) === prior) return session;
|
||||
return { ...session, sidebarOrder: prior };
|
||||
}));
|
||||
setComposerNotice("排序没有保存");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { archiveOrdersForIds } from "@/lib/people-archive-order";
|
||||
import { sidebarOrdersForIds } from "@/lib/session-groups";
|
||||
|
||||
const ORDER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const ORDER_BATCH_LIMIT = 1000;
|
||||
|
||||
export type OrderBatch = {
|
||||
readonly ids: string[];
|
||||
readonly orders: number[];
|
||||
};
|
||||
|
||||
export function parseOrderBatch(body: unknown): OrderBatch | null {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
||||
const record = body as { ids?: unknown; orders?: unknown };
|
||||
if (!Array.isArray(record.ids) || !Array.isArray(record.orders)) return null;
|
||||
if (record.ids.length === 0 || record.ids.length > ORDER_BATCH_LIMIT) return null;
|
||||
if (record.ids.length !== record.orders.length) return null;
|
||||
const ids: string[] = [];
|
||||
const orders: number[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < record.ids.length; index += 1) {
|
||||
const id = record.ids[index];
|
||||
const order = record.orders[index];
|
||||
if (typeof id !== "string" || !ORDER_ID.test(id) || seen.has(id)) return null;
|
||||
if (typeof order !== "number" || !Number.isInteger(order) || order < 0 || order > 1_000_000) return null;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
orders.push(order);
|
||||
}
|
||||
return { ids, orders };
|
||||
}
|
||||
|
||||
export function orderBatchErrorStatus(message: string): 400 | 404 | 500 {
|
||||
if (message.includes("order_invalid") || message.includes("order_unauthenticated")) return 400;
|
||||
if (message.includes("order_row_missing")) return 404;
|
||||
return 500;
|
||||
}
|
||||
|
||||
/** Cloud rows in one dragged group. Null when every saved order already matches. */
|
||||
export function sidebarOrderBatch<T extends { id: string; sidebarOrder?: number | null }>(
|
||||
orderedIds: readonly string[],
|
||||
sessions: readonly T[],
|
||||
isPersistent: (session: T) => boolean,
|
||||
): OrderBatch | null {
|
||||
const assigned = sidebarOrdersForIds(orderedIds);
|
||||
const byId = new Map(sessions.map((session) => [session.id, session]));
|
||||
const ids = orderedIds.filter((id) => {
|
||||
const session = byId.get(id);
|
||||
return session ? isPersistent(session) : false;
|
||||
});
|
||||
if (ids.length === 0) return null;
|
||||
const changed = ids.some((id) => (byId.get(id)?.sidebarOrder ?? null) !== (assigned.get(id) ?? null));
|
||||
if (!changed) return null;
|
||||
return { ids, orders: ids.map((id) => assigned.get(id) ?? 0) };
|
||||
}
|
||||
|
||||
/** Other people in one dragged archive. Self is never included. */
|
||||
export function archiveOrderBatch(
|
||||
orderedIds: readonly string[],
|
||||
people: readonly { id: string; archiveOrder?: number | null }[],
|
||||
): OrderBatch | null {
|
||||
const ids = orderedIds.filter((id) => id !== "self");
|
||||
const assigned = archiveOrdersForIds(ids);
|
||||
if (ids.length === 0) return null;
|
||||
const byId = new Map(people.map((person) => [person.id, person]));
|
||||
const changed = ids.some((id) => (byId.get(id)?.archiveOrder ?? null) !== (assigned.get(id) ?? null));
|
||||
if (!changed) return null;
|
||||
return { ids, orders: ids.map((id) => assigned.get(id) ?? 0) };
|
||||
}
|
||||
|
||||
export async function postOrderBatch(
|
||||
url: "/api/sessions/order" | "/api/chart-profiles/order",
|
||||
batch: OrderBatch,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await fetcher(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ ids: batch.ids, orders: batch.orders }),
|
||||
});
|
||||
if (!response.ok) throw new Error("排序没有保存");
|
||||
}
|
||||
@@ -32,11 +32,15 @@ export function sortSessions<T extends { pinned: boolean; updatedAt: number; sid
|
||||
if (pinned !== 0) return pinned;
|
||||
const leftOrder = sidebarOrderOf(left.session);
|
||||
const rightOrder = sidebarOrderOf(right.session);
|
||||
if (leftOrder !== null || rightOrder !== null) {
|
||||
if (leftOrder === null) return 1;
|
||||
if (rightOrder === null) return -1;
|
||||
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||||
// A chat the user has not dragged stays newest-first, above dragged rows.
|
||||
if (leftOrder === null && rightOrder === null) {
|
||||
const openTime = right.session.updatedAt - left.session.updatedAt;
|
||||
if (openTime !== 0) return openTime;
|
||||
return left.index - right.index;
|
||||
}
|
||||
if (leftOrder === null) return -1;
|
||||
if (rightOrder === null) return 1;
|
||||
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||||
const time = right.session.updatedAt - left.session.updatedAt;
|
||||
if (time !== 0) return time;
|
||||
return left.index - right.index;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
begin;
|
||||
|
||||
-- One call updates a caller's whole order. A missing or foreign id raises,
|
||||
-- and PostgreSQL rolls the statement back, so a partial order is never stored.
|
||||
-- security invoker keeps the write on auth.uid() and the column grants.
|
||||
|
||||
create or replace function public.set_chat_session_sidebar_order(
|
||||
p_ids uuid[],
|
||||
p_orders integer[]
|
||||
)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
set search_path = ''
|
||||
as $function$
|
||||
declare
|
||||
owner uuid := (select auth.uid());
|
||||
written integer;
|
||||
begin
|
||||
if owner is null then
|
||||
raise exception 'order_unauthenticated';
|
||||
end if;
|
||||
if p_ids is null
|
||||
or p_orders is null
|
||||
or coalesce(array_length(p_ids, 1), 0) < 1
|
||||
or coalesce(array_length(p_ids, 1), 0) > 1000
|
||||
or coalesce(array_length(p_orders, 1), 0) <> coalesce(array_length(p_ids, 1), 0)
|
||||
or exists (
|
||||
select 1
|
||||
from unnest(p_orders) as item(ord)
|
||||
where item.ord is null or item.ord < 0 or item.ord > 1000000
|
||||
)
|
||||
or (
|
||||
select count(distinct item.id)
|
||||
from unnest(p_ids) as item(id)
|
||||
) <> array_length(p_ids, 1)
|
||||
then
|
||||
raise exception 'order_invalid';
|
||||
end if;
|
||||
|
||||
update public.chat_sessions as session
|
||||
set sidebar_order = incoming.ord
|
||||
from unnest(p_ids, p_orders) as incoming(id, ord)
|
||||
where session.id = incoming.id
|
||||
and session.user_id = owner;
|
||||
|
||||
get diagnostics written = row_count;
|
||||
if written <> array_length(p_ids, 1) then
|
||||
raise exception 'order_row_missing';
|
||||
end if;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
create or replace function public.set_chart_profile_archive_order(
|
||||
p_ids uuid[],
|
||||
p_orders integer[]
|
||||
)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
set search_path = ''
|
||||
as $function$
|
||||
declare
|
||||
owner uuid := (select auth.uid());
|
||||
written integer;
|
||||
begin
|
||||
if owner is null then
|
||||
raise exception 'order_unauthenticated';
|
||||
end if;
|
||||
if p_ids is null
|
||||
or p_orders is null
|
||||
or coalesce(array_length(p_ids, 1), 0) < 1
|
||||
or coalesce(array_length(p_ids, 1), 0) > 1000
|
||||
or coalesce(array_length(p_orders, 1), 0) <> coalesce(array_length(p_ids, 1), 0)
|
||||
or exists (
|
||||
select 1
|
||||
from unnest(p_orders) as item(ord)
|
||||
where item.ord is null or item.ord < 0 or item.ord > 1000000
|
||||
)
|
||||
or (
|
||||
select count(distinct item.id)
|
||||
from unnest(p_ids) as item(id)
|
||||
) <> array_length(p_ids, 1)
|
||||
then
|
||||
raise exception 'order_invalid';
|
||||
end if;
|
||||
|
||||
update public.chart_profiles as profile
|
||||
set archive_order = incoming.ord
|
||||
from unnest(p_ids, p_orders) as incoming(id, ord)
|
||||
where profile.id = incoming.id
|
||||
and profile.user_id = owner
|
||||
and profile.role = 'other';
|
||||
|
||||
get diagnostics written = row_count;
|
||||
if written <> array_length(p_ids, 1) then
|
||||
raise exception 'order_row_missing';
|
||||
end if;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
revoke all on function public.set_chat_session_sidebar_order(uuid[], integer[]) from public, anon;
|
||||
revoke all on function public.set_chart_profile_archive_order(uuid[], integer[]) from public, anon;
|
||||
grant execute on function public.set_chat_session_sidebar_order(uuid[], integer[]) to authenticated;
|
||||
grant execute on function public.set_chart_profile_archive_order(uuid[], integer[]) to authenticated;
|
||||
|
||||
commit;
|
||||
@@ -172,6 +172,27 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
fixture.psql("select has_column_privilege('authenticated', 'public.chart_profiles', 'archive_order', 'UPDATE')"),
|
||||
"t",
|
||||
);
|
||||
// 原值:只核对排序列存在,以及 authenticated 能更新 archive_order
|
||||
// 新值:整组顺序由两个 security invoker 函数一次写完,函数不复制进 frontend/db/migrations
|
||||
// 原因:U6 一次事务写完整组。列本身的语义不改
|
||||
assert.match(migration.stdout, /applied 20261026120000_sidebar_archive_order_batch\.sql/);
|
||||
assert.equal(
|
||||
existsSync(fileURLToPath(new URL("../db/migrations/20261026120000_sidebar_archive_order_batch.sql", import.meta.url))),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql("select has_function_privilege('authenticated', 'public.set_chat_session_sidebar_order(uuid[], integer[])', 'execute')"),
|
||||
"t",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql("select has_function_privilege('anon', 'public.set_chat_session_sidebar_order(uuid[], integer[])', 'execute')"),
|
||||
"f",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql("select has_function_privilege('authenticated', 'public.set_chart_profile_archive_order(uuid[], integer[])', 'execute')"),
|
||||
"t",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
closeLocalPostgresDataPools,
|
||||
createLocalPostgresDataClient,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
|
||||
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
|
||||
const schemaPassword = "schema-owner-test-password";
|
||||
|
||||
const ownerSessionA = "11111111-1111-4111-8111-1111111114a1";
|
||||
const ownerSessionB = "11111111-1111-4111-8111-1111111114a2";
|
||||
const ownerSessionC = "11111111-1111-4111-8111-1111111114a3";
|
||||
const otherSession = "22222222-2222-4222-8222-2222222224a4";
|
||||
const ownerProfileP = "33333333-3333-4333-8333-3333333334a5";
|
||||
const ownerProfileQ = "33333333-3333-4333-8333-3333333334a6";
|
||||
const otherProfile = "44444444-4444-4444-8444-4444444444a7";
|
||||
|
||||
function windowsMigrationDirectory(): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "jyotisha-order-migrations-"));
|
||||
const seen = new Map<string, Buffer>();
|
||||
for (const relative of ["../db/migrations", "../supabase/migrations"]) {
|
||||
const source = fileURLToPath(new URL(relative, import.meta.url));
|
||||
for (const name of readdirSync(source)) {
|
||||
if (!/^\d{14}_[a-z0-9_]+\.sql$/.test(name)) continue;
|
||||
const bytes = readFileSync(join(source, name));
|
||||
if (/^\.\.\/.+\.sql$/.test(bytes.toString("utf8").trim())) continue;
|
||||
const previous = seen.get(name);
|
||||
if (previous) {
|
||||
assert.equal(bytes.equals(previous), true, `${name} mirrors must match`);
|
||||
continue;
|
||||
}
|
||||
seen.set(name, bytes);
|
||||
writeFileSync(join(directory, name), bytes);
|
||||
}
|
||||
}
|
||||
assert.ok(seen.has("20261026120000_sidebar_archive_order_batch.sql"));
|
||||
return directory;
|
||||
}
|
||||
|
||||
function applyMigrations(fixture: PostgresFixture): void {
|
||||
const env = {
|
||||
...process.env,
|
||||
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", schemaPassword),
|
||||
};
|
||||
const first = spawnSync(process.execPath, [runner], { encoding: "utf8", env });
|
||||
if (first.status === 0) return;
|
||||
const detail = `${first.stderr ?? ""}\n${first.stdout ?? ""}`;
|
||||
assert.match(detail, /duplicate migration filename/, detail);
|
||||
const directory = windowsMigrationDirectory();
|
||||
try {
|
||||
const second = spawnSync(process.execPath, [runner], {
|
||||
encoding: "utf8",
|
||||
env: { ...env, MIGRATIONS_DIRECTORY: directory },
|
||||
});
|
||||
assert.equal(second.status, 0, second.stderr || second.stdout);
|
||||
} finally {
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function rpcError(error: unknown): string {
|
||||
if (!error || typeof error !== "object") return "";
|
||||
const message = (error as { message?: unknown }).message;
|
||||
return typeof message === "string" ? message : "";
|
||||
}
|
||||
|
||||
test("order batch rereads the caller's rows and leaves the group unchanged when one id is foreign", {
|
||||
skip: docker ? false : "docker unavailable",
|
||||
}, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
try {
|
||||
applyMigrations(fixture);
|
||||
fixture.psql(`
|
||||
insert into identity.users (name, email, email_verified, email_verified_at)
|
||||
values
|
||||
('Fictional Order', 'order-owner@example.invalid', true, now()),
|
||||
('Fictional Other', 'order-other@example.invalid', true, now());
|
||||
`);
|
||||
const owner = fixture.psql("select id from identity.users where email = 'order-owner@example.invalid'");
|
||||
const other = fixture.psql("select id from identity.users where email = 'order-other@example.invalid'");
|
||||
fixture.psql(`
|
||||
insert into public.chat_sessions (id, user_id, title, theme, model_id, messages, session_type, updated_at)
|
||||
values
|
||||
('${ownerSessionA}', '${owner}', 'A', 'general', 'test-model', '[]', 'consultation', now()),
|
||||
('${ownerSessionB}', '${owner}', 'B', 'general', 'test-model', '[]', 'consultation', now()),
|
||||
('${ownerSessionC}', '${owner}', 'C', 'general', 'test-model', '[]', 'consultation', now()),
|
||||
('${otherSession}', '${other}', 'D', 'general', 'test-model', '[]', 'consultation', now());
|
||||
update public.chat_sessions set sidebar_order = 7 where id = '${otherSession}';
|
||||
insert into public.chart_profiles (id, user_id, role, profile)
|
||||
values
|
||||
('${ownerProfileP}', '${owner}', 'other', '{"name":"虚构甲","chartRelationship":"friend"}'::jsonb),
|
||||
('${ownerProfileQ}', '${owner}', 'other', '{"name":"虚构乙","chartRelationship":"friend"}'::jsonb),
|
||||
('${otherProfile}', '${other}', 'other', '{"name":"虚构丙","chartRelationship":"friend"}'::jsonb);
|
||||
update public.chart_profiles set archive_order = 7 where id = '${otherProfile}';
|
||||
`);
|
||||
|
||||
const sessionOrder = (id: string) => fixture.psql(
|
||||
`select coalesce(sidebar_order::text, 'null') || '|' || updated_at::text from public.chat_sessions where id = '${id}'`,
|
||||
);
|
||||
const profileOrder = (id: string) => fixture.psql(
|
||||
`select coalesce(archive_order::text, 'null') || '|' || updated_at::text from public.chart_profiles where id = '${id}'`,
|
||||
);
|
||||
const beforeA = sessionOrder(ownerSessionA);
|
||||
const beforeC = sessionOrder(ownerSessionC);
|
||||
const beforeOtherSession = sessionOrder(otherSession);
|
||||
const beforeP = profileOrder(ownerProfileP);
|
||||
const beforeOtherProfile = profileOrder(otherProfile);
|
||||
|
||||
const client = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
|
||||
{ id: owner, email: "order-owner@example.invalid" },
|
||||
"authenticated",
|
||||
);
|
||||
const saved = await client.rpc("set_chat_session_sidebar_order", {
|
||||
p_ids: [ownerSessionB, ownerSessionA],
|
||||
p_orders: [10, 20],
|
||||
});
|
||||
assert.equal(saved.error, null, rpcError(saved.error));
|
||||
assert.equal(sessionOrder(ownerSessionB).startsWith("10|"), true);
|
||||
assert.equal(sessionOrder(ownerSessionA).startsWith("20|"), true);
|
||||
assert.equal(sessionOrder(ownerSessionA).slice(sessionOrder(ownerSessionA).indexOf("|")), beforeA.slice(beforeA.indexOf("|")));
|
||||
assert.equal(sessionOrder(ownerSessionC), beforeC);
|
||||
assert.equal(sessionOrder(otherSession), beforeOtherSession);
|
||||
|
||||
const rejected = await client.rpc("set_chat_session_sidebar_order", {
|
||||
p_ids: [ownerSessionA, ownerSessionB, otherSession],
|
||||
p_orders: [30, 40, 50],
|
||||
});
|
||||
assert.match(rpcError(rejected.error), /order_row_missing/);
|
||||
assert.equal(sessionOrder(ownerSessionA).startsWith("20|"), true);
|
||||
assert.equal(sessionOrder(ownerSessionB).startsWith("10|"), true);
|
||||
assert.equal(sessionOrder(ownerSessionC), beforeC);
|
||||
assert.equal(sessionOrder(otherSession), beforeOtherSession);
|
||||
|
||||
const savedProfiles = await client.rpc("set_chart_profile_archive_order", {
|
||||
p_ids: [ownerProfileQ, ownerProfileP],
|
||||
p_orders: [10, 20],
|
||||
});
|
||||
assert.equal(savedProfiles.error, null, rpcError(savedProfiles.error));
|
||||
assert.equal(profileOrder(ownerProfileQ).startsWith("10|"), true);
|
||||
assert.equal(profileOrder(ownerProfileP).startsWith("20|"), true);
|
||||
assert.equal(profileOrder(ownerProfileP).slice(profileOrder(ownerProfileP).indexOf("|")), beforeP.slice(beforeP.indexOf("|")));
|
||||
assert.equal(profileOrder(otherProfile), beforeOtherProfile);
|
||||
|
||||
const rejectedProfiles = await client.rpc("set_chart_profile_archive_order", {
|
||||
p_ids: [ownerProfileP, otherProfile],
|
||||
p_orders: [30, 40],
|
||||
});
|
||||
assert.match(rpcError(rejectedProfiles.error), /order_row_missing/);
|
||||
assert.equal(profileOrder(ownerProfileP).startsWith("20|"), true);
|
||||
assert.equal(profileOrder(ownerProfileQ).startsWith("10|"), true);
|
||||
assert.equal(profileOrder(otherProfile), beforeOtherProfile);
|
||||
} finally {
|
||||
await closeLocalPostgresDataPools();
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
archiveOrderBatch,
|
||||
parseOrderBatch,
|
||||
postOrderBatch,
|
||||
sidebarOrderBatch,
|
||||
} from "../src/lib/order-batch.ts";
|
||||
|
||||
test("dragging 60 chats sends one order request for the whole group", async () => {
|
||||
const sessions = Array.from({ length: 60 }, () => ({
|
||||
id: randomUUID(),
|
||||
sidebarOrder: null as number | null,
|
||||
}));
|
||||
const ordered = sessions.map((session) => session.id).reverse();
|
||||
const batch = sidebarOrderBatch(ordered, sessions, () => true);
|
||||
assert.ok(batch);
|
||||
assert.equal(batch.ids.length, 60);
|
||||
assert.deepEqual(batch.orders, ordered.map((_, index) => (index + 1) * 10));
|
||||
const calls: Array<{ url: string; method: string; body: string }> = [];
|
||||
await postOrderBatch("/api/sessions/order", batch, async (url, init) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
method: String(init?.method),
|
||||
body: String(init?.body),
|
||||
});
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]?.url, "/api/sessions/order");
|
||||
assert.equal(calls[0]?.method, "POST");
|
||||
const body = JSON.parse(calls[0]?.body ?? "{}") as { ids: string[] };
|
||||
assert.equal(body.ids.length, 60);
|
||||
});
|
||||
|
||||
test("an unsaved chat stays out of the order request, and a failed request throws", async () => {
|
||||
const local = { id: randomUUID(), sidebarOrder: null as number | null };
|
||||
const cloud = { id: randomUUID(), sidebarOrder: null as number | null };
|
||||
const batch = sidebarOrderBatch(
|
||||
[local.id, cloud.id],
|
||||
[local, cloud],
|
||||
(session) => session.id === cloud.id,
|
||||
);
|
||||
assert.deepEqual(batch, { ids: [cloud.id], orders: [20] });
|
||||
await assert.rejects(
|
||||
() => postOrderBatch("/api/sessions/order", batch!, async () => new Response("{}", { status: 500 })),
|
||||
/排序没有保存/,
|
||||
);
|
||||
});
|
||||
|
||||
test("archive order is one batch and never includes self", () => {
|
||||
const first = randomUUID();
|
||||
const second = randomUUID();
|
||||
assert.equal(archiveOrderBatch(["self", first], [{ id: first, archiveOrder: 10 }]), null);
|
||||
assert.deepEqual(
|
||||
archiveOrderBatch(["self", second, first], [
|
||||
{ id: first, archiveOrder: 10 },
|
||||
{ id: second, archiveOrder: 20 },
|
||||
]),
|
||||
{ ids: [second, first], orders: [10, 20] },
|
||||
);
|
||||
});
|
||||
|
||||
test("order batch rejects a short, duplicate, or non-integer body", () => {
|
||||
const id = "11111111-1111-4111-8111-111111111449";
|
||||
assert.equal(parseOrderBatch(null), null);
|
||||
assert.equal(parseOrderBatch({ ids: [], orders: [] }), null);
|
||||
assert.equal(parseOrderBatch({ ids: [id], orders: [1, 2] }), null);
|
||||
assert.equal(parseOrderBatch({ ids: [id, id], orders: [10, 20] }), null);
|
||||
assert.equal(parseOrderBatch({ ids: [id], orders: [1.5] }), null);
|
||||
assert.equal(parseOrderBatch({ ids: ["self"], orders: [10] }), null);
|
||||
assert.deepEqual(parseOrderBatch({ ids: [id], orders: [10] }), { ids: [id], orders: [10] });
|
||||
});
|
||||
|
||||
test("reorder and archive each post one batch, and the migration stays out of db/migrations", () => {
|
||||
const hook = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
|
||||
const reorder = hook.slice(hook.indexOf("function reorderSessions"), hook.indexOf("function togglePinnedSession"));
|
||||
assert.match(reorder, /postOrderBatch\(\s*"\/api\/sessions\/order"/);
|
||||
assert.doesNotMatch(reorder, /Promise\.allSettled|writeChatSession\(/);
|
||||
assert.match(reorder, /排序没有保存/);
|
||||
const page = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
|
||||
const archive = page.slice(page.indexOf("async function persistArchiveOrder"), page.indexOf("function openPerson"));
|
||||
assert.match(archive, /postOrderBatch\(\s*"\/api\/chart-profiles\/order"/);
|
||||
assert.doesNotMatch(archive, /\/api\/chart-profiles\/\$\{/);
|
||||
const sessionRoute = readFileSync(new URL("../src/app/api/sessions/order/route.ts", import.meta.url), "utf8");
|
||||
assert.equal(sessionRoute.match(/consumeUserRequestRateLimit\(/g)?.length, 1);
|
||||
assert.match(sessionRoute, /set_chat_session_sidebar_order/);
|
||||
const archiveRoute = readFileSync(new URL("../src/app/api/chart-profiles/order/route.ts", import.meta.url), "utf8");
|
||||
assert.match(archiveRoute, /set_chart_profile_archive_order/);
|
||||
assert.doesNotMatch(archiveRoute, /consumeUserRequestRateLimit/);
|
||||
const sql = readFileSync(
|
||||
new URL("../supabase/migrations/20261026120000_sidebar_archive_order_batch.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(sql, /security invoker/);
|
||||
assert.doesNotMatch(sql, /security definer/i);
|
||||
assert.match(sql, /profile\.role = 'other'/);
|
||||
assert.match(sql, /raise exception 'order_row_missing'/);
|
||||
assert.equal(
|
||||
existsSync(new URL("../db/migrations/20261026120000_sidebar_archive_order_batch.sql", import.meta.url)),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -34,6 +34,28 @@ test("sortSessions keeps the original order when pin and time are equal", () =>
|
||||
assert.deepEqual(sortSessions([first, second]).map((item) => item.id), ["a", "b"]);
|
||||
});
|
||||
|
||||
test("sortSessions keeps chats the user has not ordered above dragged rows", () => {
|
||||
const dragged = { id: "dragged", pinned: false, updatedAt: 100, sidebarOrder: 10 };
|
||||
const olderOpen = { id: "older", pinned: false, updatedAt: 200, sidebarOrder: null };
|
||||
const newest = { id: "newest", pinned: false, updatedAt: 900 };
|
||||
assert.deepEqual(
|
||||
sortSessions([dragged, olderOpen, newest]).map((item) => item.id),
|
||||
["newest", "older", "dragged"],
|
||||
);
|
||||
});
|
||||
|
||||
test("sortSessions still pins a dragged row above a newer open chat", () => {
|
||||
const pinnedDragged = { id: "pin", pinned: true, updatedAt: 100, sidebarOrder: 10 };
|
||||
const newest = { id: "newest", pinned: false, updatedAt: 900 };
|
||||
assert.deepEqual(sortSessions([newest, pinnedDragged]).map((item) => item.id), ["pin", "newest"]);
|
||||
});
|
||||
|
||||
test("sortSessions keeps a new pinned chat above pinned chats the user ordered", () => {
|
||||
const ordered = { id: "ordered", pinned: true, updatedAt: 500, sidebarOrder: 10 };
|
||||
const fresh = { id: "fresh", pinned: true, updatedAt: 100 };
|
||||
assert.deepEqual(sortSessions([ordered, fresh]).map((item) => item.id), ["fresh", "ordered"]);
|
||||
});
|
||||
|
||||
test("sortSessions does not drop archived rows", () => {
|
||||
const archived = { ...session("arc", 50), archivedAt: "2026-09-01T00:00:00.000Z" };
|
||||
const live = session("live", 80);
|
||||
|
||||
Reference in New Issue
Block a user