diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index e74c06bfc..066e3bc0d 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -25,6 +25,22 @@ import { validateSlug, contentHash, rowToPage, rowToChunk, rowToSearchResult, pa import { resolveBoostMap, resolveHardExcludes } from './search/source-boost.ts'; import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause } from './search/sql-ranking.ts'; +function escapeSqlStringLiteral(value: string): string { + return value.replace(/'/g, "''"); +} + +export function getPostgresSchema(dims: number = 1536, model: string = 'text-embedding-3-large'): string { + const parsedDims = Number(dims); + if (!Number.isInteger(parsedDims) || parsedDims <= 0) { + throw new Error(`Invalid embedding dimensions: ${dims}`); + } + const sanitizedModel = escapeSqlStringLiteral(String(model)); + return applyChunkEmbeddingIndexPolicy(SCHEMA_SQL, parsedDims) + .replace(/vector\(1536\)/g, `vector(${parsedDims})`) + .replace(/'text-embedding-3-large'/g, `'${sanitizedModel}'`) + .replace(/\('embedding_dimensions', '1536'\)/g, `('embedding_dimensions', '${parsedDims}')`); +} + // CONNECTION_ERROR_PATTERNS / isConnectionError were used by the per-call // executeRaw retry that #406 originally shipped. Eng-review D3 dropped that // retry as unsound (regex idempotence-boundary doesn't hold for writable @@ -111,9 +127,7 @@ export class PostgresEngine implements BrainEngine { model = gw.getEmbeddingModel().split(':').slice(1).join(':') || model; } catch { /* gateway not yet configured — use defaults */ } - const sql = applyChunkEmbeddingIndexPolicy(SCHEMA_SQL, dims) - .replace(/vector\(1536\)/g, `vector(${dims})`) - .replace(/'text-embedding-3-large'/g, `'${model}'`); + const sql = getPostgresSchema(dims, model); // Advisory lock prevents concurrent initSchema() calls from deadlocking // on DDL statements (DROP TRIGGER + CREATE TRIGGER acquire AccessExclusiveLock). diff --git a/test/ai/schema-templating.test.ts b/test/ai/schema-templating.test.ts index 2b6092875..c19f80eaa 100644 --- a/test/ai/schema-templating.test.ts +++ b/test/ai/schema-templating.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from 'bun:test'; import { getPGLiteSchema, PGLITE_SCHEMA_SQL } from '../../src/core/pglite-schema.ts'; +import { getPostgresSchema } from '../../src/core/postgres-engine.ts'; describe('getPGLiteSchema', () => { test('default produces v0.13-compatible schema (1536d + text-embedding-3-large)', () => { @@ -41,3 +42,19 @@ describe('getPGLiteSchema', () => { expect(PGLITE_SCHEMA_SQL).toBe(getPGLiteSchema()); }); }); + +describe('getPostgresSchema', () => { + test('Voyage 2048d updates vector column and seeded config but skips HNSW', () => { + const sql = getPostgresSchema(2048, 'voyage-4-large'); + expect(sql).toMatch(/vector\(2048\)/); + expect(sql).toMatch(/\('embedding_model', 'voyage-4-large'\)/); + expect(sql).toMatch(/\('embedding_dimensions', '2048'\)/); + expect(sql).not.toContain('idx_chunks_embedding ON content_chunks USING hnsw'); + }); + + test('escapes configured model before inserting into schema SQL literals', () => { + const sql = getPostgresSchema(1024, "voyage-weird'quoted"); + expect(sql).toContain("'voyage-weird''quoted'"); + expect(sql).not.toContain("'voyage-weird'quoted'"); + }); +});