fix(web): support PostgREST not(col, is) so archived session lists load
Independent Staging Quality Gate / validate (push) Successful in 10m31s
Independent Staging Quality Gate / publish (push) Successful in 3m37s

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:
jesse-ux
2026-09-21 17:45:07 +08:00
parent 70d595e361
commit df329fa96c
8 changed files with 137 additions and 9 deletions
@@ -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();
+33
View File
@@ -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();
}
});