fix(web): support PostgREST not(col, is) so archived session lists load
Local Postgres not() only handled eq/cs, so archived=1 threw and the sidebar archive view returned 500. Compile is not null/true/false instead of inequality.
This commit is contained in:
@@ -123,6 +123,11 @@
|
||||
- ~~**替代:** `GET /api/sessions` 排除 `messages = []`,响应带 `draft`;启动和「新建对话」优先用这份空咨询,不再连点就堆新行。~~
|
||||
- **解除(2026-09-21,BUG-989):** `startNewChat` 只在本地开一条,第一问 `send()` 才 `POST /api/sessions`;未落库不写 `?c=`。列表过滤收窄到咨询空行,校正会话不再被 `messages = []` 误删(BUG-987)。刷新丢掉未开口的本地空会话是可接受的。
|
||||
|
||||
## 会话列表归档:本机 Docker 网段耗尽未跑 test:db(2026-09-21,BUG-990)
|
||||
|
||||
- **Docker 在 PATH**,但 `startPostgresFixture` 建 compose 网络报 `all predefined address pools have been fully subnetted`(其它 worktree 留下的 postgres 网络占满)。**未跑 `npm run test:db`,不得写成通过。**
|
||||
- **替代证据:** `frontend/tests/local-postgres-not.test.ts`:`not("archived_at","is",null)` 编译 `"archived_at" is not null` 且不含 `<>`;`not(...,"is",undefined)` throw `unsupported not filter`。真实 Postgres 断言仍在 `database-session-list-visibility.test.ts`,最终验证交给门禁机。BUG-990 保持 investigating。
|
||||
|
||||
## 会话列表多键排序:无 Docker 未跑翻页重叠(2026-09-17,BUG-926)
|
||||
|
||||
- **无 Docker:** 本机 `docker` 不在 PATH。任务书要求 `npm run test:db` 插入三条不同 `updated_at` 的会话,`GET /api/sessions?limit=2` 返回最新两条且 `nextCursor` 翻页拿到第三条、无重叠。**未跑,不得写成通过。**
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-21 — 归档记录可以打开了
|
||||
|
||||
侧栏点「归档记录」不再报读不到聊天。Skill 版本不变。
|
||||
|
||||
## 2026-09-21 — 侧栏重新列出生时校正,时间按最后活动,新建对话开口前不落库
|
||||
|
||||
侧栏不再把生时校正会话藏起来。每条下面的时间改成最后一次说话或校正操作的时间,和列表排序、分组一致。点「新建对话」先只在这一页开着,真正发出第一句话才存到云端;连点不会堆出一排空的「新对话」。Skill 版本不变。
|
||||
|
||||
@@ -13103,3 +13103,19 @@
|
||||
- 相关记录:BUG-928、BUG-987
|
||||
- 复发自:BUG-928(延迟落库让步未收口)
|
||||
- 修复版本:待本修复合入 staging
|
||||
|
||||
## BUG-990 | 侧栏切到归档记录后 500
|
||||
|
||||
- 状态:investigating
|
||||
- 首次发现:2026-09-21
|
||||
- 最近更新:2026-09-21
|
||||
- 影响面:`LocalPostgresQueryBuilder.not()`、`GET /api/sessions?archived=1`、侧栏「归档记录」
|
||||
- 用户现象:点侧栏「归档记录」后聊天记录读不出来(500「聊天记录暂时无法读取」)。
|
||||
- 触发条件:自托管本地 Postgres 上打开归档视图。托管 Supabase 不受影响。
|
||||
- 根因:兼容层 `not()` 只实现 PostgREST 算子子集 `eq` / `cs`。`applyArchiveFilter(..., true)` 调用 `not("archived_at", "is", null)`,构造期不报错,真跑 SQL 时 throw `unsupported not filter`,被列表路由外层 catch 成 500。该调用自 `a1956deb`(BUG-553)就在,此前没有真实数据库测试走到归档分支。
|
||||
- 修复:`not()` 增加 `is` 分支,新 filter kind `isNot`,编译为 `is not null` / `is not true` / `is not false`。不得写成 `<> null`(恒为 NULL,会静默 0 行)。
|
||||
- 验证:`local-postgres-not.test.ts`;`database-session-list-visibility.test.ts` 归档分支(有 Docker 时)。**本记录在 `test:db` 或门禁该测试转绿前不得标 resolved。**
|
||||
- 防复发:兼容层新增算子必须有真实 Postgres 覆盖,不得只加源码正则。
|
||||
- 相关记录:BUG-553、BUG-926、BUG-987
|
||||
- 复发自:BUG-926(同一兼容层的不同缺口:当时是 `order()` 只留最后一键)
|
||||
- 修复版本:待本修复合入 staging 且门禁该测试转绿
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# PROGRESS · 会话列表重建验收修复单(2026-09-21)
|
||||
|
||||
工作树:`.worktrees/session-list-rebuild-fix-20260921`
|
||||
分支:`codex/session-list-rebuild-fix-20260921`
|
||||
基线:`origin/staging` @ `70d595e3`(修复单;代码基线 `e71e4f92`)
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| F1 兼容层 `not(col,"is",…)` BUG-990 | 完成(实现) | `isNot` → `is not null/true/false`;未改 `applyArchiveFilter` |
|
||||
| F2 归档空咨询 fixture | 完成 | 归档视图不返回已归档的空咨询;两视图互斥且对上 `cloudListIncludesSession` |
|
||||
| F3 记录 | 完成 | BUG-990 先标 investigating,等 `test:db` 或门禁转绿再 resolved |
|
||||
|
||||
## 实现要点
|
||||
|
||||
- `LocalPostgresQueryBuilder.not()` 增加 `operator === "is"`,新 filter kind `isNot`。
|
||||
- `compileUnaryIsClause` 编译 `is [not] null|true|false`;其余取值 throw。不走 `<>`。
|
||||
- `database-session-list-visibility.test.ts` 原断言保留,另加已归档空咨询一行。
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error / **119 warning**(未增) |
|
||||
| 定向 | `local-postgres-not` / `local-postgres-or` / `chat-session-authority` / `session-list-filter` 通过 |
|
||||
| `npm test` | **3600** 条,**3512** 通过 / **88** 失败。较本机上次 `e71e4f92` 全量(3598 / 3509 / 89)净增 2 绿(`local-postgres-not` 两条)。失败仍是本机 Windows Docker 网段 + symlink/chmod/脚本,不是本单新逻辑。 |
|
||||
| `npm run test:db` | Docker 网段耗尽,**未跑通**。见 `BLOCKED.md`。BUG-990 保持 investigating,最终验证交给门禁。 |
|
||||
| `next build` | 本轮未跑(Windows skill symlink EPERM 已知缺口,与本单无关) |
|
||||
@@ -153,7 +153,7 @@
|
||||
| `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 已验收 | `cc1a8980`(Claude 验收:tsc 0 / lint 0 error / npm test 3457 条 39 红与 11c0028d 逐条一致、新增 2 条绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 591,242(较 09-16 基线 582,800 +1.45%,含会话列表单)/ Chrome 真实布局 S1–S6 全部通过,S6 新助手行距顶 16px 且增高不动,S5 不再写留白);真机六条欠 |
|
||||
| — | `PROGRESS-starter-greeting-20260917.md` | 首页开场语改成 claude.ai 式单行问候:`starter-greeting.ts` 的「称呼 + 追问句」五时段十五条收成三个池子(时段 / 星期 / 回访,按 `variantSelection` 确定性取一条),副标题行与 `.starter-salutation` 下线,h1 降到 `clamp(24px, 2.8vw, 30px)`;追问移到输入框占位符「想聊什么都可以」。产品直接拍板,非 Bug,不占 BUG 号 | 待验收 | `codex/starter-greeting-20260917` |
|
||||
| `TASK-session-list-rebuild-20260921.md` | `PROGRESS-session-list-rebuild-20260921.md` | **P0 会话列表重建(`e4e73f56` 的返工)**:服务端 `GET /api/sessions` 用 `messages <> '[]'` 排除空咨询,但校正会话的 `messages` 永远是 `[]`(建行写死空数组、创建合同只收空数组、PATCH 忽略、`touch_chat_session_from_rectification_case` 只 bump `updated_at`)→ **全部生时校正会话从侧栏消失**,而客户端 `isListedSidebarSession` 明确放行校正会话,两层规则相反且无跨层对断(BUG-987);同一提交把副标题换成 `created_at`,排序/分组/游标仍是 `updated_at`,列表可见时间不单调(9/18→9/17→9/7→9/16)(BUG-988);BUG-928 让步下来的「复用空会话」不 bump 时间,点新建变成跳回 9/17 的旧草稿(BUG-989)。**产品 09-21 拍板**:D1 副标题改最后活动时间(推翻 BUG-929 该条,其余保留)、D2 收掉 `BLOCKED.md:120` 的让步,第一问前不落库、删复用与 `draft`。T1 不得让步,T3 可拆轮。BUG 段 987 起 | **验收未通过(1 条 P1)** | `e71e4f92`(Claude 验收:T1/T2/T3 实现均正确;tsc 0 / lint 0 error 119 warning 与基线同 / `npm test` 本机 3640 条 34 红与 `f8d65e48` 逐条一致、净增 5 绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 643,115 较基线 −0.027% / page.tsx 1826→1791。**P1:门禁 run 2832 红、staging 未部署**——新加的真实 Postgres 测试在有 Docker 的门禁机上跑出 `unsupported not filter`,兼容层 `not()` 不支持 `is` 算子,归档分支炸。修复单 `TASK-session-list-rebuild-fix-20260921.md`) |
|
||||
| `TASK-session-list-rebuild-fix-20260921.md` | — | **验收修复单(挡着三条修复上线)**:本地 PG 兼容层 `LocalPostgresQueryBuilder.not()` 只认 `eq` / `cs` 两个算子,`not("archived_at","is",null)` 直接 throw → `GET /api/sessions?archived=1` 落外层 catch 回 500,**侧栏「归档记录」自 `a1956deb`(09-06,BUG-553)起一直打不开**,两周无人发现是因为只有源码正则合同看着它;本轮新加的真实 Postgres 测试第一次真跑到这条路径,把它照出来,于是门禁 run 2832 红、`e71e4f92` 没部署。**决策**:在兼容层补 `is not null / is not true / is not false`,不得反过来改调用方绕开,不得删改测试断言让门禁变绿。与 BUG-926 的 `order()` 缺口同类。BUG 段 990 起 | 待领取 | — |
|
||||
| `TASK-session-list-rebuild-fix-20260921.md` | `PROGRESS-session-list-rebuild-fix-20260921.md` | **验收修复单(挡着三条修复上线)**:本地 PG 兼容层 `LocalPostgresQueryBuilder.not()` 只认 `eq` / `cs` 两个算子,`not("archived_at","is",null)` 直接 throw → `GET /api/sessions?archived=1` 落外层 catch 回 500,**侧栏「归档记录」自 `a1956deb`(09-06,BUG-553)起一直打不开**,两周无人发现是因为只有源码正则合同看着它;本轮新加的真实 Postgres 测试第一次真跑到这条路径,把它照出来,于是门禁 run 2832 红、`e71e4f92` 没部署。**决策**:在兼容层补 `is not null / is not true / is not false`,不得反过来改调用方绕开,不得删改测试断言让门禁变绿。与 BUG-926 的 `order()` 缺口同类。BUG 段 990 起 | 待验收 | `codex/session-list-rebuild-fix-20260921` |
|
||||
|
||||
### 个人报告
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ type Filter =
|
||||
| Readonly<{ kind: "like"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "in"; column: string; value: readonly unknown[] }>
|
||||
| Readonly<{ kind: "is"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "isNot"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "notContains"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "or"; expression: string }>;
|
||||
|
||||
@@ -46,6 +47,19 @@ function identifier(value: string): string {
|
||||
return `"${normalized}"`;
|
||||
}
|
||||
|
||||
export function compileUnaryIsClause(
|
||||
column: string,
|
||||
value: unknown,
|
||||
negated: boolean,
|
||||
): string {
|
||||
const quoted = identifier(column);
|
||||
const target = value === null ? "null" : value === true ? "true" : value === false ? "false" : null;
|
||||
if (target === null) {
|
||||
throw new Error(negated ? "unsupported not filter" : "unsupported is filter");
|
||||
}
|
||||
return `${quoted} is${negated ? " not" : ""} ${target}`;
|
||||
}
|
||||
|
||||
export function formatOrderClause(
|
||||
ordering: readonly { column: string; ascending: boolean }[],
|
||||
): string {
|
||||
@@ -388,6 +402,11 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
this.filters.push({ kind: "neq", column, value });
|
||||
return this;
|
||||
}
|
||||
if (operator === "is") {
|
||||
compileUnaryIsClause(column, value, true);
|
||||
this.filters.push({ kind: "isNot", column, value });
|
||||
return this;
|
||||
}
|
||||
if (operator !== "cs") throw new Error("unsupported not filter");
|
||||
this.filters.push({ kind: "notContains", column, value });
|
||||
return this;
|
||||
@@ -451,10 +470,10 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
}
|
||||
const column = identifier(filter.column);
|
||||
if (filter.kind === "is") {
|
||||
if (filter.value === null) return `${column} is null`;
|
||||
if (filter.value === true) return `${column} is true`;
|
||||
if (filter.value === false) return `${column} is false`;
|
||||
throw new Error("unsupported is filter");
|
||||
return compileUnaryIsClause(filter.column, filter.value, false);
|
||||
}
|
||||
if (filter.kind === "isNot") {
|
||||
return compileUnaryIsClause(filter.column, filter.value, true);
|
||||
}
|
||||
if (filter.kind === "in") {
|
||||
if (filter.value.length === 0) return "false";
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
closeLocalPostgresDataPools,
|
||||
createLocalPostgresDataClient,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { applyArchiveFilter, excludeEmptyConsultations } from "../src/lib/session-list-filter.ts";
|
||||
import {
|
||||
applyArchiveFilter,
|
||||
cloudListIncludesSession,
|
||||
excludeEmptyConsultations,
|
||||
} from "../src/lib/session-list-filter.ts";
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
|
||||
@@ -17,6 +21,7 @@ const emptyConsultation = "11111111-1111-4111-8111-111111111111";
|
||||
const emptyRectification = "22222222-2222-4222-8222-222222222222";
|
||||
const filledConsultation = "33333333-3333-4333-8333-333333333333";
|
||||
const archivedRectification = "44444444-4444-4444-8444-444444444444";
|
||||
const archivedEmptyConsultation = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
test("session list query keeps empty rectification rows and drops empty consultations", {
|
||||
skip: docker ? false : "docker unavailable",
|
||||
@@ -43,7 +48,8 @@ test("session list query keeps empty rectification rows and drops empty consulta
|
||||
('${emptyConsultation}', '${userId}', 'Empty consult', 'general', 'test-model', '[]', 'consultation', false, null, now()),
|
||||
('${emptyRectification}', '${userId}', 'Empty rectification', 'general', 'test-model', '[]', 'birth_time_rectification', false, null, now()),
|
||||
('${filledConsultation}', '${userId}', 'Filled consult', 'general', 'test-model', '[{"role":"user","text":"问一句"}]', 'consultation', false, null, now()),
|
||||
('${archivedRectification}', '${userId}', 'Archived rectification', 'general', 'test-model', '[]', 'birth_time_rectification', false, '2026-09-08T00:00:00Z', now());
|
||||
('${archivedRectification}', '${userId}', 'Archived rectification', 'general', 'test-model', '[]', 'birth_time_rectification', false, '2026-09-08T00:00:00Z', now()),
|
||||
('${archivedEmptyConsultation}', '${userId}', 'Archived empty consult', 'general', 'test-model', '[]', 'consultation', false, '2026-09-08T00:00:00Z', now());
|
||||
`);
|
||||
const local = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
|
||||
@@ -65,9 +71,24 @@ test("session list query keeps empty rectification rows and drops empty consulta
|
||||
),
|
||||
);
|
||||
assert.equal(archived.error, null, archived.error?.message);
|
||||
const archivedIds = (archived.data as { id: string }[]).map((row) => row.id).sort();
|
||||
assert.deepEqual(archivedIds, [archivedRectification]);
|
||||
assert.equal(archivedIds.includes(archivedEmptyConsultation), false);
|
||||
assert.equal(liveIds.some((id) => archivedIds.includes(id)), false);
|
||||
const rows = [
|
||||
{ id: emptyConsultation, sessionType: "consultation" as const, messagesEmpty: true, archived: false },
|
||||
{ id: emptyRectification, sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: false },
|
||||
{ id: filledConsultation, sessionType: "consultation" as const, messagesEmpty: false, archived: false },
|
||||
{ id: archivedRectification, sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: true },
|
||||
{ id: archivedEmptyConsultation, sessionType: "consultation" as const, messagesEmpty: true, archived: true },
|
||||
];
|
||||
assert.deepEqual(
|
||||
(archived.data as { id: string }[]).map((row) => row.id),
|
||||
[archivedRectification],
|
||||
rows.filter((row) => cloudListIncludesSession({ ...row, archivedView: false })).map((row) => row.id).sort(),
|
||||
liveIds,
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.filter((row) => cloudListIncludesSession({ ...row, archivedView: true })).map((row) => row.id).sort(),
|
||||
archivedIds,
|
||||
);
|
||||
} finally {
|
||||
await closeLocalPostgresDataPools();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
closeLocalPostgresDataPools,
|
||||
compileUnaryIsClause,
|
||||
createLocalPostgresDataClient,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
|
||||
test("not(col, is, null) compiles to IS NOT NULL rather than inequality", () => {
|
||||
const sql = compileUnaryIsClause("archived_at", null, true);
|
||||
assert.equal(sql, '"archived_at" is not null');
|
||||
assert.doesNotMatch(sql, /<>/);
|
||||
assert.equal(compileUnaryIsClause("archived_at", true, true), '"archived_at" is not true');
|
||||
assert.equal(compileUnaryIsClause("archived_at", false, true), '"archived_at" is not false');
|
||||
assert.equal(compileUnaryIsClause("archived_at", null, false), '"archived_at" is null');
|
||||
});
|
||||
|
||||
test("not(col, is, undefined) still throws", async () => {
|
||||
assert.throws(() => compileUnaryIsClause("archived_at", undefined, true), /unsupported not filter/);
|
||||
const local = createLocalPostgresDataClient(
|
||||
"postgresql://unused:unused@127.0.0.1:9/unused",
|
||||
{ id: "11111111-1111-4111-8111-111111111111", email: "unused@example.com" },
|
||||
);
|
||||
try {
|
||||
assert.throws(
|
||||
() => local.from("chat_sessions").select("id").not("archived_at", "is", undefined),
|
||||
/unsupported not filter/,
|
||||
);
|
||||
} finally {
|
||||
await closeLocalPostgresDataPools();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user