From 503a16fb929480ce299153b322ae7328de151904 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 21 Oct 2026 14:15:59 +0800 Subject: [PATCH] fix(db): add gte and lt to the self-hosted query builder so synastry reports can be created createSynastryFullReport counted today's reports with .gte(), which the self-hosted client did not implement; every create threw before any credit was reserved and surfaced as a generic 500 (BUG-1399). The synastry create path now uses the shared DataQueryBuilder type, and a scan test requires every builder method used in src to exist on the self-hosted client. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 16 ++++ .../PROGRESS-synastry-gte-fix-20261021.md | 31 ++++++++ docs/tasks/README.md | 2 +- .../TASK-synastry-staging-fix-20261021.md | 6 +- frontend/src/lib/db/data-client.ts | 2 + .../src/lib/db/local-postgres-client-core.ts | 25 +++--- frontend/src/lib/synastry-full-create.ts | 13 +--- frontend/tests/database-range-filters.test.ts | 77 +++++++++++++++++++ .../tests/db-query-builder-coverage.test.ts | 67 ++++++++++++++++ .../tests/local-postgres-roundtrips.test.ts | 27 +++++++ 11 files changed, 249 insertions(+), 21 deletions(-) create mode 100644 docs/tasks/PROGRESS-synastry-gte-fix-20261021.md create mode 100644 frontend/tests/database-range-filters.test.ts create mode 100644 frontend/tests/db-query-builder-coverage.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f8037875..eac78136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-10-21 — 婚恋合盘点「开始合盘」不再直接报错 + +- 自托管数据库客户端补上「大于等于 / 小于」两种查询,合盘报告可以正常创建(BUG-1399)。Skill 版本不变。 + ## 2026-10-20 — 婚恋合盘选人页按已确认原型排版 - 选人页、新增星盘、生成中和报告页按已确认原型排版。新增是一张卡片;生成中是轨道和四步;报告有返回、日期和中英文切换。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index dc3e1e37..ed57e4de 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36952,3 +36952,19 @@ - 相关记录:BUG-275(商品保存已发布 id 被折叠成同一句错误)。 - 复发自:无。同型问题曾出现在商品目录,模型保存未一起改。 - 修复版本:分支 `codex/model-admin-draft-delete-20261020`,未提交。 + +## BUG-1399 | 婚恋合盘点「开始合盘」就报「报告生成暂时不可用」,一份都生成不了 + +- 状态:resolved(待 staging 真机复测) +- 首次发现:2026-10-21(产品负责人真机) +- 最近更新:2026-10-21 +- 影响面:`POST /api/reports`(`reportType: synastry_full`)→ `createSynastryFullReport`;自托管数据库客户端 `frontend/src/lib/db/local-postgres-client-core.ts` +- 用户现象:选好两个人点「开始合盘」,按钮下出现「报告生成暂时不可用」。不扣点。 +- 触发条件:任意一次新建合盘报告。 +- 根因:`createSynastryFullReport` 数「今天已生成几份」时用了 `.gte("created_at", …)`。自托管客户端的查询构造器没有 `gte` 方法(也没有 `lt`),内部类型和比较符表里有、方法没实现,编译分支也只认 `neq / gt / lte / like`。调用抛 TypeError,被 `app/api/reports/route.ts` 外层 `catch` 吞成通用 500。单测用合盘文件里手写的 `Query` 类型和桩,桩里自带 `gte`,所以测试与门禁全绿;共享接口 `DataQueryBuilder` 也没有 `gte`,但这段代码绕开了它。与 BUG-990 同类:兼容层没实现的写法只在真库上暴露。 +- 修复:构造器补 `gte`、`lt`,比较符统一走 `SQL_CMP`;`DataQueryBuilder` 接口补这两个方法;`synastry-full-create.ts` 删掉手写 `Query` 类型,改用 `DataQueryBuilder`,以后缺方法会在类型检查时报错。 +- 验证:`tests/local-postgres-roundtrips.test.ts` 新增一条(假连接池断言生成 `>=` / `<` 且值走参数);`tests/database-range-filters.test.ts`(真库,门禁跑:`gte` / `lt` 过滤真实行,并跑合盘「今日计数」同一条查询);`tests/db-query-builder-coverage.test.ts` 扫描 `frontend/src` 里用到的查询构造器方法,要求自托管构造器都有实现(临时删掉 `gte` 时该测试会红,已验证)。前端全量 5089 条,失败 25 条与基线同名单(均为无 Docker 的库测试),无测试消失;tsc 0;lint 0 error;构建 `/` Static,首屏 gzip 与基线同为 643,725 字节。 +- 防复发:上面的扫描测试;业务代码不得为数据库查询另写本地类型绕开 `DataQueryBuilder`。 +- 相关记录:BUG-990(同类:兼容层缺 `not(col,"is",null)`);issue #82。 +- 复发自:BUG-990 同类问题。BUG-990 的防复发是「新写法须 test:db 真查或部署后 smoke」,#82 的创建路径没有真库测试,验收也没在 staging 点过一次,所以没拦住。 +- 修复版本:分支 `codex/synastry-gte-fix-20261021`。 diff --git a/docs/tasks/PROGRESS-synastry-gte-fix-20261021.md b/docs/tasks/PROGRESS-synastry-gte-fix-20261021.md new file mode 100644 index 00000000..35fa6ab7 --- /dev/null +++ b/docs/tasks/PROGRESS-synastry-gte-fix-20261021.md @@ -0,0 +1,31 @@ +# 进度:婚恋合盘「开始合盘」即失败(P0,Claude 直接执行) + +- 任务书:`docs/tasks/TASK-synastry-staging-fix-20261021.md` 的 T1、T2(产品负责人 10-21:「P0 你改吧,其余交给执行方」) +- 基线:`origin/staging` `cb3bc330e`(代码同已部署的 `63984d4fc`) +- 分支:`codex/synastry-gte-fix-20261021` +- 记录:BUG-1399 + +## 做了什么 + +- `frontend/src/lib/db/local-postgres-client-core.ts`:查询构造器补 `gte`、`lt`;比较符统一用 `SQL_CMP`。 +- `frontend/src/lib/db/data-client.ts`:`DataQueryBuilder` 补 `gte`、`lt`。 +- `frontend/src/lib/synastry-full-create.ts`:删掉手写 `Query` 类型,改用 `DataQueryBuilder`。 + +## 测试 + +| 检查 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(122 条 warning,与基线相同) | +| `npm test` | 5089 条,失败 25 条,与 `63984d4fc` 失败名单逐条相同(均为本机无 Docker 的库测试);无测试名消失,新增 4 条 | +| 新增扫描测试的反向验证 | 临时删掉 `gte` 方法,扫描测试报出 `src/lib/synastry-full-create.ts:131 .gte()`,恢复后通过 | +| `npm run build` | `/` 仍 Static;首屏 gzip 643,725 字节,与基线相同 | +| 真库测试 `database-range-filters.test.ts` | 本机无 Docker 跳过;由 staging 门禁的真 PostgreSQL 跑(推送后核对门禁结果) | + +## 留给执行方(同一任务书 T3~T6 与红线 3) + +合盘创建主路径的完整真库测试(走到预留成功、任务入队)、时间线起止年月、英文分析逻辑、删模型后旧对话改用默认模型、选人页开关文案与闪烁、删重复的 BUG-1397。 + +## 真机复测(产品负责人) + +部署后在 staging:婚恋合盘选两个人 → 开始合盘 → 应进入等待页(不再出现「报告生成暂时不可用」)→ 几分钟后报告揭幕。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index b831e01f..74f279a7 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -509,5 +509,5 @@ | `TASK-niryaana-shoola-pvr-20261018.md` | `PROGRESS-niryaana-shoola-pvr-20261018.md` | #84 Niryaana Shoola 改 PVR 口径(我们的表与 Shoola 逐行相同;推翻 BUG-1283 所选 profile;占星师 7a773d13) | 待验收 | `codex/niryaana-shoola-pvr-20261018` | | `TASK-synastry-full-report-20261018.md` | #82 | 婚恋合盘:选人页 + 完整合盘报告(关系画像九节:结论+折叠的分析逻辑+关系时间线+技术依据摘要;英文按需;计费键 synastry.full) | 排队(等 #78、#79、#81) | `codex/synastry-full-report-20261018`;动表 | | `TASK-synastry-report-chat-20261018.md` | `PROGRESS-synastry-report-chat-20261018.md` | 从合盘报告某一节进入对话问依据(绑定报告与节、对话里可用术语、只依据已存计算结果并逐条核对、计费照普通对话) | 已验收 | `549e37b99`;动表 | -| `TASK-synastry-staging-fix-20261021.md` | — | 婚恋合盘 staging 点「开始合盘」即失败(兼容层缺 `.gte`)+ 时间线无起止、英文缺分析逻辑、删模型后旧对话改用默认模型(BUG-1399 起) | 待提 issue | `codex/synastry-staging-fix-20261021`;动表 | +| `TASK-synastry-staging-fix-20261021.md` | — | 婚恋合盘 staging 点「开始合盘」即失败(兼容层缺 `.gte`,P0 已由 Claude 修,BUG-1399)+ 时间线无起止、英文缺分析逻辑、删模型后旧对话改用默认模型(执行方从 T3 做起,BUG-1400 起) | P0 已推 staging;其余待提 issue | `codex/synastry-staging-fix-20261021`;动表 | | `TASK-synastry-report-chat-fix-20261020.md` | `PROGRESS-synastry-report-chat-20261018.md` | 大运核对要认报告里的「小运」和英文 mahadasha / antardasha;预填文案只留一句 | 已验收 | `549e37b99` | diff --git a/docs/tasks/TASK-synastry-staging-fix-20261021.md b/docs/tasks/TASK-synastry-staging-fix-20261021.md index 69b6d0bf..77a4ce83 100644 --- a/docs/tasks/TASK-synastry-staging-fix-20261021.md +++ b/docs/tasks/TASK-synastry-staging-fix-20261021.md @@ -44,6 +44,10 @@ | T5 | K1:对话解析不到绑定的模型时,改用当前默认模型继续,并把会话的 `model_id / model_config_version` 更新成新模型;没有任何可用模型时才报现有的「模型不可用」 | 单测:被删版本的会话下一轮用默认模型;无默认模型时仍报错 | | T6 | 选人页:开关读取失败显示「合盘服务暂时连不上,请稍后再试」;开关结果回来之前不渲染选人区(只渲染标题和副标题);删掉 BUG_HISTORY 里重复的 BUG-1397 一段;CHANGELOG、PROGRESS(含真机复测步骤:选两人生成一份、等待页、报告揭幕、切英文、从末尾进对话问一节) | 渲染测试;文档齐 | +## 已完成(Claude 直接执行,产品负责人 10-21 授权「P0 你改吧,其余交给执行方」) + +T1、T2 已由 Claude 在 `codex/synastry-gte-fix-20261021` 完成并推 staging(BUG-1399,进度见 `PROGRESS-synastry-gte-fix-20261021.md`):构造器补 `gte` / `lt`、`DataQueryBuilder` 补方法、合盘创建改用共享类型、假连接池 SQL 测试、真库过滤测试、扫描测试。**执行方从 T3 做起**,另外仍要补红线 3 的「合盘创建主路径真库测试」(Claude 只做了查询级的真库测试)。 + ## 让步顺序 T1、T2 最先做,单独一个提交先推 staging(门禁绿、部署后告知产品负责人可以复测生成);T3~T6 随后。 @@ -63,4 +67,4 @@ grep -n "BUG-1[0-9][0-9][0-9]" docs/BUG_HISTORY.md | tail -3 ## BUG 编号 -从 **BUG-1399** 起(10-21 staging 最大 BUG-1398),开工时核对。 +BUG-1399 已由 Claude 的 P0 使用;执行方从 **BUG-1400** 起,开工时核对。 diff --git a/frontend/src/lib/db/data-client.ts b/frontend/src/lib/db/data-client.ts index 6b65cc63..04cdeb39 100644 --- a/frontend/src/lib/db/data-client.ts +++ b/frontend/src/lib/db/data-client.ts @@ -21,6 +21,8 @@ export interface DataQueryBuilder extends PromiseLike { eq(column: string, value: unknown): DataQueryBuilder; neq(column: string, value: unknown): DataQueryBuilder; gt(column: string, value: unknown): DataQueryBuilder; + gte(column: string, value: unknown): DataQueryBuilder; + lt(column: string, value: unknown): DataQueryBuilder; lte(column: string, value: unknown): DataQueryBuilder; like(column: string, value: unknown): DataQueryBuilder; in(column: string, value: readonly unknown[]): DataQueryBuilder; diff --git a/frontend/src/lib/db/local-postgres-client-core.ts b/frontend/src/lib/db/local-postgres-client-core.ts index 4439cb81..62c1b2c2 100644 --- a/frontend/src/lib/db/local-postgres-client-core.ts +++ b/frontend/src/lib/db/local-postgres-client-core.ts @@ -17,6 +17,8 @@ type Filter = | Readonly<{ kind: "eq"; column: string; value: unknown }> | Readonly<{ kind: "neq"; column: string; value: unknown }> | Readonly<{ kind: "gt"; column: string; value: unknown }> + | Readonly<{ kind: "gte"; column: string; value: unknown }> + | Readonly<{ kind: "lt"; column: string; value: unknown }> | Readonly<{ kind: "lte"; column: string; value: unknown }> | Readonly<{ kind: "like"; column: string; value: unknown }> | Readonly<{ kind: "in"; column: string; value: readonly unknown[] }> @@ -534,6 +536,18 @@ class LocalPostgresQueryBuilder implements DataQueryBuilder { return this; } + gte(column: string, value: unknown) { + identifier(column); + this.filters.push({ kind: "gte", column, value }); + return this; + } + + lt(column: string, value: unknown) { + identifier(column); + this.filters.push({ kind: "lt", column, value }); + return this; + } + lte(column: string, value: unknown) { identifier(column); this.filters.push({ kind: "lte", column, value }); @@ -650,16 +664,7 @@ class LocalPostgresQueryBuilder implements DataQueryBuilder { return `not (${column} @> $${parameters.length})`; } parameters.push(databaseValue(types.get(filter.column), filter.value)); - const operator = - filter.kind === "neq" - ? "<>" - : filter.kind === "gt" - ? ">" - : filter.kind === "lte" - ? "<=" - : filter.kind === "like" - ? "like" - : "="; + const operator = filter.kind === "like" ? "like" : SQL_CMP[filter.kind]; return `${column} ${operator} $${parameters.length}`; }); return ` where ${parts.join(" and ")}`; diff --git a/frontend/src/lib/synastry-full-create.ts b/frontend/src/lib/synastry-full-create.ts index 997d6562..9526927a 100644 --- a/frontend/src/lib/synastry-full-create.ts +++ b/frontend/src/lib/synastry-full-create.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { authorizeUsage, releaseUsage } from "@/lib/consultation-billing"; +import type { DataQueryBuilder } from "@/lib/db/data-client"; import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver"; import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing"; import { MARRIAGE_MATCH_RULES_VERSION, marriageMatchFingerprint } from "@/lib/marriage-match-service"; @@ -27,15 +28,9 @@ export type SynastryCreateResponse = { type Row = Record; -type QueryResult = { data: Row[] | null; error: { message?: string } | null }; - -type Query = PromiseLike & { - eq(column: string, value: unknown): Query; - in(column: string, values: readonly string[]): Query; - gte(column: string, value: string): Query; - limit(count: number): Query; - maybeSingle(): Promise<{ data: Row | null; error: { message?: string } | null }>; -}; +// The shared builder type, not a local stub: a method the self-hosted client +// lacks must fail type-checking here (BUG-1399). +type Query = DataQueryBuilder; function subjectId(value: unknown): string | null { if (value === "self") return "self"; diff --git a/frontend/tests/database-range-filters.test.ts b/frontend/tests/database-range-filters.test.ts new file mode 100644 index 00000000..d6f57736 --- /dev/null +++ b/frontend/tests/database-range-filters.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts"; +import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts"; + +// BUG-1399: `.gte()` was missing from the self-hosted client, so the synastry +// report's "reports created today" count threw before anything was reserved. +// This runs the same filters against a real database. Fictional rows only. +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 SERVICE_URL_ROLE = ["service_runtime", "service-runtime-test-password"] as const; + +function applyMigrations(fixture: PostgresFixture): void { + const result = spawnSync(process.execPath, [runner], { + encoding: "utf8", + env: { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"), + }, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); +} + +test("gte and lt filter real rows, and the synastry daily count query runs", { + skip: docker ? false : "docker unavailable", +}, async () => { + const fixture = startPostgresFixture(); + const serviceUrl = fixture.connectionUrl(...SERVICE_URL_ROLE); + try { + applyMigrations(fixture); + const owner = randomUUID(); + const older = randomUUID(); + const newer = randomUUID(); + fixture.psql(`insert into identity.users (id, name, email, email_verified, email_verified_at) + values ('${owner}', 'Fictional owner', '${owner}@example.invalid', true, now());`); + for (const [id, updatedAt, name] of [ + [older, "2026-10-01T00:00:00Z", "虚构甲"], + [newer, "2026-10-21T08:00:00Z", "虚构乙"], + ] as const) { + fixture.psql(`insert into public.chart_profiles ( + id, user_id, role, profile, updated_at, name, birth_date, reported_birth_time, + birth_time_source, latitude, longitude, timezone_id, timezone_offset, + birth_place_label, ayanamsa, birth_time_status + ) values ( + '${id}', '${owner}', 'other', '{"name":"${name}"}'::jsonb, '${updatedAt}', '${name}', '1990-01-01', '08:00', + 'family_exact', 22.3, 114.1, 'Asia/Shanghai', 8, '虚构港', 'lahiri', 'reported' + );`); + } + + const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role"); + const since = await admin.from("chart_profiles").select("id") + .eq("user_id", owner) + .gte("updated_at", "2026-10-21T00:00:00.000Z"); + assert.equal(since.error, null, since.error?.message ?? "gte failed"); + assert.deepEqual((since.data as Array<{ id: string }>).map((row) => row.id), [newer]); + + const before = await admin.from("chart_profiles").select("id") + .eq("user_id", owner) + .lt("updated_at", "2026-10-21T00:00:00.000Z"); + assert.equal(before.error, null, before.error?.message ?? "lt failed"); + assert.deepEqual((before.data as Array<{ id: string }>).map((row) => row.id), [older]); + + // The exact chain createSynastryFullReport uses for today's count. + const today = await admin.from("personal_reports").select("created_at") + .eq("user_id", owner) + .gte("created_at", "2026-10-21T00:00:00.000Z"); + assert.equal(today.error, null, today.error?.message ?? "personal_reports gte failed"); + assert.deepEqual(today.data, []); + } finally { + await closeLocalPostgresDataPool(serviceUrl); + fixture.stop(); + } +}); diff --git a/frontend/tests/db-query-builder-coverage.test.ts b/frontend/tests/db-query-builder-coverage.test.ts new file mode 100644 index 00000000..b753a6cb --- /dev/null +++ b/frontend/tests/db-query-builder-coverage.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +// BUG-1399: a feature called `.gte()` on the self-hosted database client, which +// did not implement it. Unit tests passed because they used a hand-written query +// stub that had the method. This test reads every PostgREST-style filter or +// modifier call in frontend/src and requires the self-hosted builder to declare it. +const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const srcDir = path.join(frontendDir, "src"); + +const POSTGREST_METHODS = [ + "eq", "neq", "gt", "gte", "lt", "lte", "like", "ilike", "is", "in", "not", "or", + "contains", "containedBy", "overlaps", "range", "rangeGt", "rangeGte", "rangeLt", + "rangeLte", "rangeAdjacent", "textSearch", "match", "filter", "order", "limit", + "single", "maybeSingle", "csv", "abortSignal", "returns", "throwOnError", +] as const; + +function sourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const full = path.join(dir, name); + if (statSync(full).isDirectory()) return sourceFiles(full); + return /\.(ts|tsx)$/.test(name) ? [full] : []; + }); +} + +function builderMethods(): Set { + const source = readFileSync(path.join(srcDir, "lib/db/local-postgres-client-core.ts"), "utf8"); + const start = source.indexOf("class LocalPostgresQueryBuilder"); + const end = source.indexOf("\nexport class LocalPostgresDataClient", start); + assert.ok(start >= 0 && end > start, "LocalPostgresQueryBuilder class not found"); + return new Set([...source.slice(start, end).matchAll(/^ {2}(?:async )?([a-zA-Z]+)\(/gm)].map((match) => match[1])); +} + +// Lines that are clearly not database query chains: zod schemas and plain arrays. +// `.contains(` is left out of the scan: DOM `element.contains()` uses the same name. +function isDatabaseChain(line: string): boolean { + if (/\bz\.|\.number\(\)|\.string\(\)|\.array\(|\.date\(\)/.test(line)) return false; + return true; +} + +test("every PostgREST method used in frontend/src exists on the self-hosted query builder", () => { + const implemented = builderMethods(); + const missing: string[] = []; + const builderOnly = new Set(["gte", "lt", "lte", "gt", "neq", "ilike", "maybeSingle", "containedBy", "overlaps", "rangeGt", "rangeGte", "rangeLt", "rangeLte", "rangeAdjacent", "textSearch", "csv", "throwOnError"]); + for (const file of sourceFiles(srcDir)) { + const lines = readFileSync(file, "utf8").split("\n"); + lines.forEach((line, index) => { + if (!isDatabaseChain(line)) return; + for (const method of POSTGREST_METHODS) { + if (!builderOnly.has(method)) continue; + if (new RegExp(`\\.${method}\\(`).test(line) && !implemented.has(method)) { + missing.push(`${path.relative(frontendDir, file)}:${index + 1} .${method}()`); + } + } + }); + } + assert.deepEqual(missing, [], `self-hosted builder lacks methods used in src:\n${missing.join("\n")}`); +}); + +test("the self-hosted builder declares gte and lt (BUG-1399)", () => { + const implemented = builderMethods(); + assert.ok(implemented.has("gte")); + assert.ok(implemented.has("lt")); +}); diff --git a/frontend/tests/local-postgres-roundtrips.test.ts b/frontend/tests/local-postgres-roundtrips.test.ts index 55dcc8c6..69e3b500 100644 --- a/frontend/tests/local-postgres-roundtrips.test.ts +++ b/frontend/tests/local-postgres-roundtrips.test.ts @@ -284,3 +284,30 @@ test("closing the pool drops that url from the metadata cache", async () => { assert.ok(calls.length > 0); }); }); + +test("gte and lt compile to >= and < with the value bound as a parameter (BUG-1399)", async () => { + await withSwitches({}, async () => { + const url = "postgresql://range-filters@127.0.0.1:1/jyotisha"; + const calls = installFakePool(url, { + columns: [ + { column_name: "id", udt_name: "uuid" }, + { column_name: "user_id", udt_name: "uuid" }, + { column_name: "created_at", udt_name: "timestamptz" }, + ], + }); + const client = clientFor(url, "service_role"); + const since = "2026-10-21T00:00:00.000Z"; + const until = "2026-10-22T00:00:00.000Z"; + const result = await client.from("personal_reports").select("created_at") + .eq("user_id", identity.id) + .gte("created_at", since) + .lt("created_at", until); + assert.equal(result.error, null); + const business = calls.filter((call) => tag(call.sql) === "business"); + assert.equal(business.length, 1); + const sql = business[0].sql.replace(/\s+/g, " "); + assert.match(sql, /"created_at" >= \$2/); + assert.match(sql, /"created_at" < \$3/); + assert.deepEqual(business[0].params.slice(0, 3).map(String), [identity.id, since, until].map(String)); + }); +});