Files
socraticode/tests/unit/qdrant-collection-prefix.test.ts
Giancarlo Erra 70db002796 feat(qdrant): add QDRANT_COLLECTION_PREFIX env var for shared instances
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>
2026-05-05 11:20:33 +01:00

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_");
});
});
});