fix: quota API field mapping + FTS5 query sanitization

- /api/v1/quotas read conn.id/provider/name — the lazy row proxy does not
  expose connectionId/providerId, so every connection was skipped and the
  endpoint always returned an empty list.
- retrieveMemories/buildFtsRows now sanitize the query into a quoted FTS5
  MATCH expression — natural-language queries with ? ! : ( ) etc. no longer
  throw 'fts5: syntax error' and silently degrade to empty results.
- new unit test tests/unit/memory/fts5-query-sanitize.test.ts (5 cases).
This commit is contained in:
Egor
2026-07-31 10:19:06 +03:00
parent 17c76cf5d6
commit f579f9b096
3 changed files with 55 additions and 6 deletions

View File

@@ -47,7 +47,11 @@ export async function GET(request: Request) {
? conn.providerId
: "";
const connectionId =
typeof conn.connectionId === "string" ? conn.connectionId : "";
typeof conn.id === "string"
? conn.id
: typeof conn.connectionId === "string"
? conn.connectionId
: "";
if (!provider || !connectionId) continue;
let saturation = 0;
@@ -69,7 +73,6 @@ export async function GET(request: Request) {
: typeof conn.name === "string" && conn.name.trim()
? conn.name
: connectionId;
connections.push({
provider,
connectionId,

View File

@@ -78,8 +78,21 @@ function fetchMemoriesByIds(ids: string[]): Memory[] {
return ids.map((id) => byId.get(id)).filter((m): m is Memory => m !== undefined);
}
interface FtsColConfig {
apiKeyCol: string;
/**
* Sanitize a free-text query into an FTS5 MATCH expression.
* Strips FTS5 syntax characters (?, !, ", *, :, parentheses, etc.) and quotes
* each whitespace-separated term so natural-language queries with punctuation
* don't raise "fts5: syntax error".
*/
export function toFts5MatchQuery(query: string): string {
const terms = query
.split(/\s+/)
.map((term) => term.replace(/["*():\[\]{}!?\^~+-]/g, ""))
.filter((term) => term.length > 0);
return terms.map((term) => `"${term}"`).join(" AND ");
}
interface FtsColConfig { apiKeyCol: string;
expiresCol: string;
createdCol: string;
sessionCol: string;
@@ -122,7 +135,7 @@ function buildFtsRows(apiKeyId: string, config: FtsColConfig): MemoryRow[] {
}
ftsQueryStr += ` ORDER BY f.rank LIMIT 100`;
const ftsParams: unknown[] = [q, apiKeyId];
const ftsParams: unknown[] = [toFts5MatchQuery(q), apiKeyId];
if (scope === "session" && sessionId) ftsParams.push(sessionId);
if (retentionDays && retentionDays > 0) {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
@@ -918,7 +931,9 @@ export async function retrievePreview(
const ftsQueryStr = apiKeyId
? `SELECT m.* FROM ${tableName} m JOIN memory_fts f ON m.memory_id = f.rowid WHERE f.memory_fts MATCH ? AND m.${apiKeyCol} = ? ORDER BY f.rank LIMIT ?`
: `SELECT m.* FROM ${tableName} m JOIN memory_fts f ON m.memory_id = f.rowid WHERE f.memory_fts MATCH ? ORDER BY f.rank LIMIT ?`;
const ftsP: unknown[] = apiKeyId ? [query, apiKeyId, limit] : [query, limit];
const ftsP: unknown[] = apiKeyId
? [toFts5MatchQuery(query), apiKeyId, limit]
: [toFts5MatchQuery(query), limit];
try {
ftsRows = db.prepare(ftsQueryStr).all(...ftsP) as MemoryRow[];
} catch {

View File

@@ -0,0 +1,31 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { toFts5MatchQuery } from "../../../src/lib/memory/retrieval";
describe("memory retrieval toFts5MatchQuery", () => {
it("keeps plain terms and joins with AND", () => {
assert.strictEqual(toFts5MatchQuery("hello world"), '"hello" AND "world"');
});
it("strips FTS5 syntax characters", () => {
assert.strictEqual(
toFts5MatchQuery("Чем занимается пользователь?"),
'"Чем" AND "занимается" AND "пользователь"'
);
});
it("strips quotes, colons, parentheses and dashes", () => {
assert.strictEqual(
toFts5MatchQuery('model "gpt-4o": (fast) OR NOT [x]'),
'"model" AND "gpt4o" AND "fast" AND "OR" AND "NOT" AND "x"'
);
});
it("keeps punctuation-only input as a single quoted term", () => {
assert.strictEqual(toFts5MatchQuery("???! ..."), '"..."');
});
it("returns empty string for empty input", () => {
assert.strictEqual(toFts5MatchQuery(""), "");
});
});