From ef19c33812184f0bd1d54b2619e41f60990fea1d Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Fri, 31 Jul 2026 16:21:26 -0400 Subject: [PATCH] fix(web): build a valid lquery for an exact `~` tree path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A memory at the ROOT of `~` (`me memory create --tree '~' foo`) made the UI show "Failed to load: Internal error". Two bugs: 1. `exactTreeLquery` forced the server's tree-filter classifier down the lquery branch by duplicating the last label as an alternation (`work` -> `work|work`). For a top-level home node the path IS `~`, so it emitted `~|~` — not parseable lquery, since `~` is home sugar the server expands only as a LEADING segment and is not a legal ltree label. PG rejected it with 42601. The synthetic root bucket (`.` -> `.|.`) was a second latent case. Use the zero-label quantifier instead (`~.*{0,0}`): it forces lquery detection AND pins the match to exactly that path, while staying valid with `~` leading. 2. `mapSpaceError` mapped only 22023/22P02 to VALIDATION_ERROR. ltree's lquery/ltxtquery parsers report a malformed pattern as 42601 (syntax_error), which fell through to a generic -32603 Internal error, hiding the cause. A tree filter is caller-supplied, so 42601 now maps to VALIDATION_ERROR. Fixes TNT-248 --- .../rpc/memory/memory.integration.test.ts | 45 +++++++++++++++++++ packages/server/rpc/memory/memory.ts | 7 ++- packages/web/src/api/queries.test.ts | 35 +++++++++++++++ packages/web/src/api/queries.ts | 28 +++++++----- 4 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 packages/web/src/api/queries.test.ts diff --git a/packages/server/rpc/memory/memory.integration.test.ts b/packages/server/rpc/memory/memory.integration.test.ts index 7bd5b57a..caa12f9a 100644 --- a/packages/server/rpc/memory/memory.integration.test.ts +++ b/packages/server/rpc/memory/memory.integration.test.ts @@ -785,6 +785,51 @@ test("search: tree lquery wildcard matches descendants", async () => { expect(exact.results.map((r) => r.tree)).toEqual(["/share/proj/a"]); }); +test("search: `~.*{0,0}` pins to the root of the caller's home (TNT-248)", async () => { + // The web UI's exact-path filter for an expanded tree node. A memory at the + // ROOT of `~` (not under a sub-path) is the case that regressed: the old + // filter form was `~|~`, an unparseable lquery, so this search failed with an + // opaque Internal error and the UI showed "Failed to load". + await call("memory.batchCreate", { + memories: [ + { content: "at home root", tree: "~" }, + { content: "under home", tree: "~/notes" }, + { content: "shared", tree: "share" }, + ], + }); + + const atHomeRoot = await call<{ results: { content: string }[] }>( + "memory.search", + { tree: "~.*{0,0}", limit: 1000 }, + ); + expect(atHomeRoot.results.map((r) => r.content)).toEqual(["at home root"]); + + // The same form one level down resolves the sub-path exactly. + const underHome = await call<{ results: { content: string }[] }>( + "memory.search", + { tree: "~.notes.*{0,0}", limit: 1000 }, + ); + expect(underHome.results.map((r) => r.content)).toEqual(["under home"]); +}); + +test("search: a malformed tree filter is a validation error, not an internal error", async () => { + // ltree's lquery/ltxtquery parsers report a bad pattern as `syntax_error` + // (42601), not 22P02 — it must still map to VALIDATION_ERROR so a caller sees + // what it did wrong instead of "Internal error" (the TNT-248 symptom). + // `~|~` is the exact filter the web UI used to send for a node at the root + // of home: `~` is not a legal lquery label (the server expands `~` only as a + // LEADING segment), so the ::lquery cast raises 42601. + await expectAppError( + call("memory.search", { tree: "~|~" }), + "VALIDATION_ERROR", + ); + // An `&` classifies the filter as ltxtquery; `&&` is not valid there. + await expectAppError( + call("memory.search", { tree: "share.a&&b" }), + "VALIDATION_ERROR", + ); +}); + test("deleteOrphansInTree deletes stale slots; kept/foreign/unnamed survive; dryRun previews", async () => { await call("memory.batchCreate", { memories: [ diff --git a/packages/server/rpc/memory/memory.ts b/packages/server/rpc/memory/memory.ts index 27b63f0e..b589a349 100644 --- a/packages/server/rpc/memory/memory.ts +++ b/packages/server/rpc/memory/memory.ts @@ -91,13 +91,18 @@ const MAX_SEMANTIC_QUERY_CHARS = 8192; * `insufficient_privilege` (42501) on access violations and * `invalid_parameter_value` (22023) / `invalid_text_representation` (22P02) * on malformed input; everything else propagates as an internal error. + * + * `syntax_error` (42601) is also caller-caused here: ltree's `lquery` / + * `ltxtquery` parsers report a malformed pattern (`~|~`, `a&&`) with that + * code rather than 22P02, and a tree filter is caller-supplied — so it must + * surface as a validation error, not an opaque "Internal error". */ function mapSpaceError(e: unknown): never { const code = (e as { code?: string }).code; if (code === "42501") { throw new AppError("FORBIDDEN", "Insufficient tree access"); } - if (code === "22023" || code === "22P02") { + if (code === "22023" || code === "22P02" || code === "42601") { throw new AppError( "VALIDATION_ERROR", e instanceof Error ? e.message : "Invalid parameter", diff --git a/packages/web/src/api/queries.test.ts b/packages/web/src/api/queries.test.ts new file mode 100644 index 00000000..a7fcd87c --- /dev/null +++ b/packages/web/src/api/queries.test.ts @@ -0,0 +1,35 @@ +/** + * Unit tests for the pure query-param helpers (no network, no React). + */ +import { describe, expect, test } from "bun:test"; +import { ROOT_PATH } from "../lib/tree-build.ts"; +import { exactTreeLquery } from "./queries.ts"; + +describe("exactTreeLquery", () => { + test("the empty path and the synthetic root bucket both pin to the root", () => { + // `*{0,0}` matches an ltree of exactly zero labels — the empty tree. + expect(exactTreeLquery("")).toBe("*{0,0}"); + expect(exactTreeLquery(ROOT_PATH)).toBe("*{0,0}"); + }); + + test("a concrete path allows zero further labels (exact match)", () => { + expect(exactTreeLquery("work")).toBe("work.*{0,0}"); + expect(exactTreeLquery("work.projects")).toBe("work.projects.*{0,0}"); + expect(exactTreeLquery("share.auth")).toBe("share.auth.*{0,0}"); + }); + + test("the `~` home sugar stays a valid leading segment (TNT-248)", () => { + // Regression: the old label-alternation form produced `~|~`, which is not + // parseable as an lquery (the server expands `~` only as a LEADING + // segment), so `memory.search` crashed with an Internal error whenever a + // memory lived at the root of the caller's home. + expect(exactTreeLquery("~")).toBe("~.*{0,0}"); + expect(exactTreeLquery("~.notes")).toBe("~.notes.*{0,0}"); + }); + + test("never emits an lquery alternation, which `~` cannot express", () => { + for (const path of ["", ROOT_PATH, "~", "~.a.b", "work", "share.auth"]) { + expect(exactTreeLquery(path)).not.toContain("|"); + } + }); +}); diff --git a/packages/web/src/api/queries.ts b/packages/web/src/api/queries.ts index 8b0d84c8..5959002c 100644 --- a/packages/web/src/api/queries.ts +++ b/packages/web/src/api/queries.ts @@ -17,6 +17,7 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; +import { ROOT_PATH } from "../lib/tree-build.ts"; import { memoryClient } from "./client.ts"; const SEARCH_LIMIT = 1000; @@ -37,21 +38,26 @@ function memoryToDot(m: T): T { /** * Convert an exact ltree path to an lquery pattern that matches only that * path (no descendants). The engine's tree filter auto-detects lquery vs - * ltree by special characters; by duplicating the last label via `|` or - * using the zero-label quantifier for the empty path, we force lquery - * detection while preserving exact-match semantics. + * ltree by special characters, so we append the zero-label quantifier + * `*{0,0}`: it forces lquery detection AND pins the match to exactly this + * path (zero further labels allowed). + * + * The quantifier is used rather than duplicating the last label as an + * alternation (`work|work`) because the latter produces invalid lquery for + * the `~` home sugar (`~|~` — a `~` is not a legal lquery label, and the + * server expands `~` only as a leading segment) and for the synthetic root + * sentinel, which crashed the search RPC with a PG syntax error. * * Examples: - * "" -> "*{0,0}" matches only the empty tree - * "work" -> "work|work" matches only `work` - * "work.projects" -> "work.projects|projects" matches only `work.projects` + * "" -> "*{0,0}" matches only the empty tree + * "." -> "*{0,0}" the synthetic root bucket + * "work" -> "work.*{0,0}" matches only `work` + * "work.projects" -> "work.projects.*{0,0}" matches only `work.projects` + * "~" -> "~.*{0,0}" matches only the caller's home */ export function exactTreeLquery(path: string): string { - if (path === "") return "*{0,0}"; - const labels = path.split("."); - const i = labels.length - 1; - labels[i] = `${labels[i]}|${labels[i]}`; - return labels.join("."); + if (path === "" || path === ROOT_PATH) return "*{0,0}"; + return `${path}.*{0,0}`; } /**