Skip to content

Commit 6ee7151

Browse files
committed
Verify the FTS language on the serving path before the first query
Migration-time verification only protects hosts that run the migration runner at boot; one that migrated once and later changed FTS_LANGUAGE would silently degrade recall with nothing rechecking. The knowledge plane now awaits the same read-only catalog check, memoized, before its first search or capture — the fail-fast validate step (Hibernate validate, Rails check_all_pending!) adapted to a synchronous mount. Success is remembered forever; a failure clears the memo so a transient connection error at first use does not poison the plane until restart. The verifier is exported for hosts that prefer running it from a real readiness probe.
1 parent 19158fb commit 6ee7151

5 files changed

Lines changed: 103 additions & 4 deletions

File tree

‎IMPLEMENTATION.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,13 @@ An ordered slice of a version's text, keyed by `(version_id, ordinal)`
131131
that powers the lexical search channel — this is the only place FTS is
132132
computed; no separate FTS table exists. Its language comes from
133133
`FTS_LANGUAGE` at migration time; the query side binds the same configured
134-
language as a `regconfig` parameter, and `runKnowledgeMigrations` verifies
135-
the column's actual language against the configuration (read back from the
136-
catalog) so a mismatch fails at startup instead of silently degrading
137-
recall. Chunks are **never** reused across
134+
language as a `regconfig` parameter. The invariant is verified twice, both
135+
read-only against the catalog: `runKnowledgeMigrations` checks after
136+
applying (the deploy step), and the knowledge plane runs the same check
137+
once, memoized, before its first query (the serving path) — so a mismatch
138+
or unmigrated schema fails loudly on first use regardless of who ran the
139+
migrations. Hosts with a readiness probe can call the exported
140+
`verifyFtsLanguage` there instead. Chunks are **never** reused across
138141
versions — every new version gets a fresh full insert of its own chunks.
139142

140143
### `knowledge_entity` / `knowledge_edge`

‎src/core/fts-language.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
22
import { readFile } from "node:fs/promises";
33
import { join } from "node:path";
44
import {
5+
createFtsVerification,
56
DEFAULT_FTS_LANGUAGE,
67
FTS_LANGUAGE_TOKEN,
78
parseFtsLanguage,
@@ -91,3 +92,60 @@ describe("verifyFtsLanguage", () => {
9192
).rejects.toThrow("schema not migrated");
9293
});
9394
});
95+
96+
describe("createFtsVerification", () => {
97+
const ENGLISH_EXPR = "to_tsvector('english'::regconfig, text)";
98+
99+
it("verifies once and memoizes success", async () => {
100+
let calls = 0;
101+
const ensure = createFtsVerification(
102+
{
103+
query: (sqlText: string) => {
104+
calls += 1;
105+
return Promise.resolve(
106+
sqlText.includes("pg_ts_config") ? [{ ok: 1 }] : [{ expr: ENGLISH_EXPR }],
107+
);
108+
},
109+
},
110+
"english",
111+
);
112+
await ensure();
113+
await ensure();
114+
// one pg_ts_config lookup + one catalog read, never repeated
115+
expect(calls).toBe(2);
116+
});
117+
118+
it("clears the memo on failure so a transient error does not poison the plane", async () => {
119+
let attempt = 0;
120+
const ensure = createFtsVerification(
121+
{
122+
query: (sqlText: string) => {
123+
if (attempt === 0 && sqlText.includes("pg_ts_config")) {
124+
attempt += 1;
125+
return Promise.reject(new Error("connection refused"));
126+
}
127+
return Promise.resolve(
128+
sqlText.includes("pg_ts_config") ? [{ ok: 1 }] : [{ expr: ENGLISH_EXPR }],
129+
);
130+
},
131+
},
132+
"english",
133+
);
134+
await expect(ensure()).rejects.toThrow("connection refused");
135+
await ensure();
136+
});
137+
138+
it("keeps throwing on a real mismatch", async () => {
139+
const ensure = createFtsVerification(
140+
{
141+
query: (sqlText: string) =>
142+
Promise.resolve(
143+
sqlText.includes("pg_ts_config") ? [{ ok: 1 }] : [{ expr: ENGLISH_EXPR }],
144+
),
145+
},
146+
"german",
147+
);
148+
await expect(ensure()).rejects.toThrow('built with "english"');
149+
await expect(ensure()).rejects.toThrow('built with "english"');
150+
});
151+
});

‎src/core/fts-language.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,24 @@ export function parseFtsLanguage(raw: string | undefined): string {
3030
return raw;
3131
}
3232

33+
/**
34+
* Memoized serving-path wrapper around verifyFtsLanguage: the first call
35+
* verifies, success is remembered forever, and a failure clears the memo so
36+
* a transient DB error at first use does not poison the plane until
37+
* restart (a real mismatch keeps failing on every retry anyway).
38+
*/
39+
export function createFtsVerification(
40+
client: FtsVerifySqlClient,
41+
ftsLanguage: string,
42+
): () => Promise<void> {
43+
let verified: Promise<void> | undefined;
44+
return () =>
45+
(verified ??= verifyFtsLanguage(client, ftsLanguage).catch((err) => {
46+
verified = undefined;
47+
throw err;
48+
}));
49+
}
50+
3351
interface FtsVerifySqlClient {
3452
query: (sql: string, params: readonly unknown[]) => Promise<Array<Record<string, unknown>>>;
3553
}

‎src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type { KnowledgePlane } from "./knowledge.ts";
2727
export { CaptureLog, type CaptureEvent } from "./capture-log.ts";
2828
// Migrations
2929
export { runKnowledgeMigrations } from "./migrations.ts";
30+
export { parseFtsLanguage, verifyFtsLanguage } from "./core/fts-language.ts";
3031
// Granular mount (compose your own)
3132
export { mountKnowledgeRoutes, type GrantConfig } from "./routes/mount.ts";
3233

‎src/knowledge.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import type { EngineConfig } from "./config.ts";
66
import { log } from "./log.ts";
77
import { createDb, type Db, type RawSql } from "./db/client.ts";
8+
import { createFtsVerification } from "./core/fts-language.ts";
89
import type { VisibilitySpec } from "./core/schemas/document.ts";
910
import { captureDocument } from "./services/capture.ts";
1011
import {
@@ -57,9 +58,26 @@ export function createKnowledgePlane(config: KnowledgeConfig): KnowledgePlane {
5758
const { db, sql }: { db: Db; sql: RawSql } = createDb(engineConfig);
5859
const deps = { db, sql, config: engineConfig };
5960

61+
// Serving-path schema validation, industry-standard fail-fast shape
62+
// (Hibernate validate / Rails check_all_pending!): the mount is
63+
// synchronous, so "before accepting traffic" becomes a memoized check
64+
// awaited by the first query. Read-only; migration stays a deploy step.
65+
// Hosts with a real readiness probe can call verifyFtsLanguage there
66+
// instead — this memo then resolves against an already-verified schema.
67+
const ensureVerified = createFtsVerification(
68+
{
69+
query: async (text, params) =>
70+
(await sql.unsafe(text, [...params] as never[])) as unknown as Array<
71+
Record<string, unknown>
72+
>,
73+
},
74+
engineConfig.ftsLanguage,
75+
);
76+
6077
return {
6178
async search(params) {
6279
try {
80+
await ensureVerified();
6381
const result = await hybridSearch(deps, {
6482
tenantId: params.tenantId,
6583
principalId: params.principalId,
@@ -114,6 +132,7 @@ export function createKnowledgePlane(config: KnowledgeConfig): KnowledgePlane {
114132
},
115133

116134
async capture(params) {
135+
await ensureVerified();
117136
const adapter = params.adapter ?? "mcp";
118137
const externalRef =
119138
params.externalRef ??

0 commit comments

Comments
 (0)