mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
70db002796
Resolves #49. Reported by @awbait. When sharing a single Qdrant server across multiple applications (SocratiCode + Open-WebUI + custom RAG, etc.) or across multiple SocratiCode instances (per-project, per-environment, per-user), the fixed `codebase_<id>` / `codegraph_<id>` / `context_<id>` / `<id>_symgraph_*` / `socraticode_metadata` collection names risk colliding with other apps and prevent isolation between SocratiCode instances. This patch adds an optional QDRANT_COLLECTION_PREFIX env var that, when set, is prepended verbatim to every Qdrant collection name SocratiCode creates, queries, lists, or deletes. Default empty string preserves the existing collection names exactly: fully backwards compatible. Touchpoints (mechanical, no logic changes): - src/constants.ts: new QDRANT_COLLECTION_PREFIX export with eager validation. Qdrant accepts only [a-zA-Z0-9_-] in collection names; an invalid prefix throws at module load with a message naming the offending value, before any Qdrant call is attempted. - src/config.ts: all six collection-name generators (collectionName, graphCollectionName, contextCollectionName, symgraphMetaCollectionName, symgraphFileCollectionName, symgraphIndexCollectionName) prepend the prefix. Generator semantics are otherwise unchanged. - src/services/qdrant.ts: METADATA_COLLECTION (the global socraticode_metadata collection used for cross-project state) also honours the prefix, so two SocratiCode instances on one Qdrant keep their metadata isolated as well as their per-project collections. The two startsWith() filters in listCodebaseCollections — used by codebase_list_projects to discover this instance's collections — build the match prefix from QDRANT_COLLECTION_PREFIX so a prefixed instance only sees its own collections, not those of co-tenants. - src/tools/manage-tools.ts: codebase_list_projects similarly uses the prefix in its filters. The projectId extraction (formerly c.replace("codebase_", "")) now slices the full ${prefix}codebase_ token so the recovered id is correct under any prefix; the codegraph cross-reference uses the same prefixed name. Tests: 20 new test cases in tests/unit/qdrant-collection-prefix.test.ts covering: - Default empty prefix preserves the legacy collection-name forms for all six generators (regression guard against backward-compat break). - Empty-string env var is treated identically to unset. - Non-empty prefix prepends correctly to all six generators, including the suffix-style symgraph names. - Two different prefixes produce disjoint collection-name sets for the same projectId (the multi-instance isolation property). - Validation rejects whitespace, slash, colon, and unicode characters. - The error message includes the offending value for discoverability. - Validation accepts the full set of legal characters. Existing 752 unit tests continue to pass unchanged. Total: 772. typecheck, biome, and CodeRabbit local review all clean. README updated to document the new env var alongside the other QDRANT_* settings, including the user-side responsibility to remove old collections when changing prefix between runs. Co-authored-by: awbait <awbait@users.noreply.github.com>
179 lines
7.1 KiB
TypeScript
179 lines
7.1 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
|
|
//
|
|
// Tests for QDRANT_COLLECTION_PREFIX (issue #49).
|
|
//
|
|
// QDRANT_COLLECTION_PREFIX is read once at module load in
|
|
// `src/constants.ts` and used by `src/config.ts` collection-name
|
|
// generators (and by `src/services/qdrant.ts` for the metadata
|
|
// collection). To exercise multiple prefix values in one test run we
|
|
// reset the module cache between cases and dynamically re-import the
|
|
// modules under test, which forces the env-var-reading IIFE in
|
|
// constants.ts to execute again.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const ENV_KEY = "QDRANT_COLLECTION_PREFIX";
|
|
|
|
describe("QDRANT_COLLECTION_PREFIX", () => {
|
|
const original = process.env[ENV_KEY];
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (original === undefined) {
|
|
delete process.env[ENV_KEY];
|
|
} else {
|
|
process.env[ENV_KEY] = original;
|
|
}
|
|
vi.resetModules();
|
|
});
|
|
|
|
describe("default (empty prefix) — backwards compatibility", () => {
|
|
it("collectionName returns the legacy `codebase_<id>` form", async () => {
|
|
delete process.env[ENV_KEY];
|
|
const { collectionName } = await import("../../src/config.js");
|
|
expect(collectionName("abc123def456")).toBe("codebase_abc123def456");
|
|
});
|
|
|
|
it("graphCollectionName returns the legacy `codegraph_<id>` form", async () => {
|
|
delete process.env[ENV_KEY];
|
|
const { graphCollectionName } = await import("../../src/config.js");
|
|
expect(graphCollectionName("abc123def456")).toBe("codegraph_abc123def456");
|
|
});
|
|
|
|
it("contextCollectionName returns the legacy `context_<id>` form", async () => {
|
|
delete process.env[ENV_KEY];
|
|
const { contextCollectionName } = await import("../../src/config.js");
|
|
expect(contextCollectionName("abc123def456")).toBe("context_abc123def456");
|
|
});
|
|
|
|
it("symgraphMetaCollectionName returns the legacy `<id>_symgraph_meta` form", async () => {
|
|
delete process.env[ENV_KEY];
|
|
const { symgraphMetaCollectionName } = await import("../../src/config.js");
|
|
expect(symgraphMetaCollectionName("abc123def456")).toBe("abc123def456_symgraph_meta");
|
|
});
|
|
|
|
it("symgraphFileCollectionName returns the legacy `<id>_symgraph_file` form", async () => {
|
|
delete process.env[ENV_KEY];
|
|
const { symgraphFileCollectionName } = await import("../../src/config.js");
|
|
expect(symgraphFileCollectionName("abc123def456")).toBe("abc123def456_symgraph_file");
|
|
});
|
|
|
|
it("symgraphIndexCollectionName returns the legacy `<id>_symgraph_index` form", async () => {
|
|
delete process.env[ENV_KEY];
|
|
const { symgraphIndexCollectionName } = await import("../../src/config.js");
|
|
expect(symgraphIndexCollectionName("abc123def456")).toBe("abc123def456_symgraph_index");
|
|
});
|
|
|
|
it("treats empty-string env var the same as unset", async () => {
|
|
process.env[ENV_KEY] = "";
|
|
const { collectionName } = await import("../../src/config.js");
|
|
expect(collectionName("abc")).toBe("codebase_abc");
|
|
});
|
|
});
|
|
|
|
describe("non-empty prefix", () => {
|
|
it("prepends the prefix to collectionName", async () => {
|
|
process.env[ENV_KEY] = "myapp_";
|
|
const { collectionName } = await import("../../src/config.js");
|
|
expect(collectionName("abc123")).toBe("myapp_codebase_abc123");
|
|
});
|
|
|
|
it("prepends the prefix to graphCollectionName", async () => {
|
|
process.env[ENV_KEY] = "myapp_";
|
|
const { graphCollectionName } = await import("../../src/config.js");
|
|
expect(graphCollectionName("abc123")).toBe("myapp_codegraph_abc123");
|
|
});
|
|
|
|
it("prepends the prefix to contextCollectionName", async () => {
|
|
process.env[ENV_KEY] = "myapp_";
|
|
const { contextCollectionName } = await import("../../src/config.js");
|
|
expect(contextCollectionName("abc123")).toBe("myapp_context_abc123");
|
|
});
|
|
|
|
it("prepends the prefix to symgraph collection names (suffix-style still gets prefix)", async () => {
|
|
process.env[ENV_KEY] = "myapp_";
|
|
const {
|
|
symgraphMetaCollectionName,
|
|
symgraphFileCollectionName,
|
|
symgraphIndexCollectionName,
|
|
} = await import("../../src/config.js");
|
|
expect(symgraphMetaCollectionName("abc")).toBe("myapp_abc_symgraph_meta");
|
|
expect(symgraphFileCollectionName("abc")).toBe("myapp_abc_symgraph_file");
|
|
expect(symgraphIndexCollectionName("abc")).toBe("myapp_abc_symgraph_index");
|
|
});
|
|
|
|
it("accepts prefixes without trailing separator (user choice)", async () => {
|
|
process.env[ENV_KEY] = "myapp";
|
|
const { collectionName } = await import("../../src/config.js");
|
|
// The user may or may not include a separator; we prepend verbatim.
|
|
expect(collectionName("abc")).toBe("myappcodebase_abc");
|
|
});
|
|
|
|
it("accepts prefixes with hyphen separator", async () => {
|
|
process.env[ENV_KEY] = "team-alpha-";
|
|
const { collectionName } = await import("../../src/config.js");
|
|
expect(collectionName("abc")).toBe("team-alpha-codebase_abc");
|
|
});
|
|
|
|
it("two different prefixes produce disjoint collection-name sets for the same projectId", async () => {
|
|
process.env[ENV_KEY] = "instance_a_";
|
|
const { collectionName: collA } = await import("../../src/config.js");
|
|
const nameA = collA("project1");
|
|
|
|
vi.resetModules();
|
|
process.env[ENV_KEY] = "instance_b_";
|
|
const { collectionName: collB } = await import("../../src/config.js");
|
|
const nameB = collB("project1");
|
|
|
|
expect(nameA).toBe("instance_a_codebase_project1");
|
|
expect(nameB).toBe("instance_b_codebase_project1");
|
|
expect(nameA).not.toBe(nameB);
|
|
});
|
|
});
|
|
|
|
describe("validation", () => {
|
|
it("rejects a prefix containing whitespace", async () => {
|
|
process.env[ENV_KEY] = "my app";
|
|
await expect(import("../../src/constants.js")).rejects.toThrow(
|
|
/Invalid QDRANT_COLLECTION_PREFIX/,
|
|
);
|
|
});
|
|
|
|
it("rejects a prefix containing a slash", async () => {
|
|
process.env[ENV_KEY] = "team/alpha";
|
|
await expect(import("../../src/constants.js")).rejects.toThrow(
|
|
/Invalid QDRANT_COLLECTION_PREFIX/,
|
|
);
|
|
});
|
|
|
|
it("rejects a prefix containing a colon", async () => {
|
|
process.env[ENV_KEY] = "team:alpha";
|
|
await expect(import("../../src/constants.js")).rejects.toThrow(
|
|
/Invalid QDRANT_COLLECTION_PREFIX/,
|
|
);
|
|
});
|
|
|
|
it("rejects a prefix containing emoji or other unicode", async () => {
|
|
process.env[ENV_KEY] = "rocket🚀";
|
|
await expect(import("../../src/constants.js")).rejects.toThrow(
|
|
/Invalid QDRANT_COLLECTION_PREFIX/,
|
|
);
|
|
});
|
|
|
|
it("includes the offending value in the error message for discoverability", async () => {
|
|
process.env[ENV_KEY] = "bad value";
|
|
await expect(import("../../src/constants.js")).rejects.toThrow(/"bad value"/);
|
|
});
|
|
|
|
it("accepts a prefix containing only ASCII letters, digits, underscore, hyphen", async () => {
|
|
process.env[ENV_KEY] = "a_B-9-z_";
|
|
const constants = await import("../../src/constants.js");
|
|
expect(constants.QDRANT_COLLECTION_PREFIX).toBe("a_B-9-z_");
|
|
});
|
|
});
|
|
});
|