feat: reserve one qdrant collection per workspace
This commit is contained in:
@@ -2,17 +2,18 @@ import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseAllDocuments, stringify } from "yaml";
|
||||
import { parseWorkspaceYaml, type LegacyWorkspace, type WorkspaceDescriptor } from "./schema.js";
|
||||
import { parseWorkspaceYaml, validateOperationalWorkspace, type WorkspaceDescriptor, type WorkspaceV3 } from "./schema.js";
|
||||
|
||||
export interface LegacyMigrationResult {
|
||||
state: "migration_required";
|
||||
source: string;
|
||||
workspace: LegacyWorkspace;
|
||||
workspace: WorkspaceV3;
|
||||
}
|
||||
|
||||
export interface LegacyMigrationOptions {
|
||||
/** Immutable repository identifier, normally derived from the input filename by the CLI. */
|
||||
id: string;
|
||||
/** Required Qdrant collection name for the migrated schema-v3 descriptor. */
|
||||
collection: string;
|
||||
}
|
||||
|
||||
type LegacyRecord = Record<string, unknown>;
|
||||
@@ -86,38 +87,28 @@ function vectorFrom(source: LegacyRecord): {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a legacy runtime descriptor into a versioned, readable v1 registry descriptor.
|
||||
* Runtime YAMLs mix shared metadata with `${ENV}` bindings and omit semantic-index identity;
|
||||
* the result therefore always remains `migration_required` until an operator explicitly upgrades
|
||||
* it with the correct collection/database/schema contract.
|
||||
* Converts a legacy runtime descriptor into a schema-v3 registry descriptor.
|
||||
* Runtime YAMLs mix shared metadata with `${ENV}` bindings and omit internal semantic identity,
|
||||
* so the operator must explicitly choose the target Qdrant collection during migration.
|
||||
*/
|
||||
export function migrateLegacyWorkspace(source: string, options: LegacyMigrationOptions): LegacyMigrationResult {
|
||||
if (!workspaceId.test(options.id)) throw new Error("legacy workspace ID is invalid");
|
||||
const collection = typeof options.collection === "string" && workspaceId.test(options.collection)
|
||||
? options.collection
|
||||
: undefined;
|
||||
if (collection === undefined) throw new Error("legacy migration requires an explicit target collection");
|
||||
const legacy = sourceDocument(source);
|
||||
const language = legacy.language === "it" ? "it" : "en";
|
||||
const { section: dwh, transport: dwhTransport } = dwhFrom(legacy);
|
||||
const { section: vector, transport: vectorTransport, writer } = vectorFrom(legacy);
|
||||
const { section: vector } = vectorFrom(legacy);
|
||||
const embedding = record(legacy.embeddings) ?? {};
|
||||
const dwhDatabase = literalIdentifier(dwh.database) ?? "legacy_dwh";
|
||||
const dwhSchema = literalIdentifier(dwh.schema) ?? "public";
|
||||
const vectorDatabase = literalIdentifier(vector.database);
|
||||
const vectorSchema = literalIdentifier(vector.schema);
|
||||
const dimensions = typeof embedding.dim === "number" && Number.isInteger(embedding.dim) && embedding.dim > 0
|
||||
? embedding.dim
|
||||
: 768;
|
||||
const vectorStore: LegacyWorkspace["semantic_index"]["vector_store"] = {
|
||||
engine: "pgvector",
|
||||
collection: `${options.id.replaceAll("-", "_")}_documents`,
|
||||
dimensions,
|
||||
distance: "cosine",
|
||||
supported_transports: [vectorTransport],
|
||||
...(literalPort(vector.port) === undefined ? {} : { port: literalPort(vector.port) }),
|
||||
...(vectorDatabase === undefined ? {} : { database: vectorDatabase }),
|
||||
...(vectorSchema === undefined ? {} : { schema: vectorSchema }),
|
||||
};
|
||||
const workspace: LegacyWorkspace = {
|
||||
void vector;
|
||||
void embedding;
|
||||
const workspace = validateOperationalWorkspace({
|
||||
workspace: {
|
||||
schema_version: 1,
|
||||
schema_version: 3,
|
||||
id: options.id,
|
||||
name: titleFor(options.id),
|
||||
language,
|
||||
@@ -130,21 +121,22 @@ export function migrateLegacyWorkspace(source: string, options: LegacyMigrationO
|
||||
...(literalPort(dwh.port) === undefined ? {} : { port: literalPort(dwh.port) }),
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: vectorStore,
|
||||
...(writer ? { vector_writer: {} } : {}),
|
||||
vector_store: {
|
||||
engine: "qdrant",
|
||||
collection,
|
||||
dimensions: 1024,
|
||||
distance: "cosine",
|
||||
},
|
||||
embedding: {
|
||||
provider: "ollama_compatible",
|
||||
model: literalText(embedding.model) ?? "legacy-embedding",
|
||||
dimensions,
|
||||
provider: "ollama_internal",
|
||||
model: "qwen3-embedding:0.6b",
|
||||
dimensions: 1024,
|
||||
},
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
};
|
||||
const descriptor = parseWorkspaceYaml(stringify(workspace, { lineWidth: 0, sortMapEntries: true }));
|
||||
if (descriptor.workspace.schema_version !== 1) throw new Error("legacy workspace migration is invalid");
|
||||
const migrated = descriptor as LegacyWorkspace;
|
||||
const rendered = stringify(migrated, { lineWidth: 0, sortMapEntries: true });
|
||||
return { state: "migration_required", source: rendered, workspace: migrated };
|
||||
});
|
||||
const rendered = stringify(workspace, { lineWidth: 0, sortMapEntries: true });
|
||||
return { source: rendered, workspace };
|
||||
}
|
||||
|
||||
function destinationFor(repositoryRoot: string, id: string): string {
|
||||
@@ -175,36 +167,40 @@ export async function writeMigratedWorkspace(result: LegacyMigrationResult, repo
|
||||
return destination;
|
||||
}
|
||||
|
||||
function parseCliArguments(argv: readonly string[]): { input: string; output: string; id?: string } {
|
||||
if (argv.length !== 4 && argv.length !== 6) {
|
||||
throw new Error("usage: migrate-legacy --input <legacy-workspace.yaml> --output <repository-root> [--id <workspace-id>]");
|
||||
function parseCliArguments(argv: readonly string[]): { input: string; output: string; id?: string; collection: string } {
|
||||
if (argv.length !== 6 && argv.length !== 8) {
|
||||
throw new Error("usage: migrate-legacy --input <legacy-workspace.yaml> --output <repository-root> --collection <qdrant-collection> [--id <workspace-id>]");
|
||||
}
|
||||
const options = new Map<string, string>();
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const flag = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if ((flag !== "--input" && flag !== "--output" && flag !== "--id") || value === undefined || options.has(flag)) {
|
||||
throw new Error("usage: migrate-legacy --input <legacy-workspace.yaml> --output <repository-root> [--id <workspace-id>]");
|
||||
if ((flag !== "--input" && flag !== "--output" && flag !== "--id" && flag !== "--collection") || value === undefined || options.has(flag)) {
|
||||
throw new Error("usage: migrate-legacy --input <legacy-workspace.yaml> --output <repository-root> --collection <qdrant-collection> [--id <workspace-id>]");
|
||||
}
|
||||
options.set(flag, value);
|
||||
}
|
||||
const input = options.get("--input");
|
||||
const output = options.get("--output");
|
||||
const id = options.get("--id");
|
||||
if (input === undefined || output === undefined) {
|
||||
throw new Error("usage: migrate-legacy --input <legacy-workspace.yaml> --output <repository-root> [--id <workspace-id>]");
|
||||
const collection = options.get("--collection");
|
||||
if (input === undefined || output === undefined || collection === undefined) {
|
||||
throw new Error("usage: migrate-legacy --input <legacy-workspace.yaml> --output <repository-root> --collection <qdrant-collection> [--id <workspace-id>]");
|
||||
}
|
||||
if (!isAbsolute(input) || !isAbsolute(output)) {
|
||||
throw new Error("migration input and output paths must be absolute");
|
||||
}
|
||||
if (id !== undefined && !workspaceId.test(id)) throw new Error("legacy workspace ID is invalid");
|
||||
return { input, output, id };
|
||||
if (!workspaceId.test(collection)) {
|
||||
throw new Error("legacy migration requires an explicit target collection");
|
||||
}
|
||||
return { input, output, id, collection };
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
const { input, output, id: explicitId } = parseCliArguments(argv);
|
||||
const { input, output, id: explicitId, collection } = parseCliArguments(argv);
|
||||
const id = explicitId ?? basename(input, ".yaml");
|
||||
const result = migrateLegacyWorkspace(await readFile(input, "utf8"), { id });
|
||||
const result = migrateLegacyWorkspace(await readFile(input, "utf8"), { id, collection });
|
||||
const destination = await writeMigratedWorkspace(result, output);
|
||||
process.stdout.write(`${destination}\n`);
|
||||
}
|
||||
|
||||
@@ -211,6 +211,9 @@ export class WorkspaceRegistry {
|
||||
const state = await this.activeState();
|
||||
const revision = state.revisions.find((candidate) => candidate.id === id);
|
||||
if (!revision) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable");
|
||||
if (revision.state !== "operational") {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable");
|
||||
}
|
||||
let workspace: WorkspaceDescriptor;
|
||||
try {
|
||||
workspace = parseWorkspaceYaml(await readFile(revision.snapshotPath, "utf8"));
|
||||
@@ -477,6 +480,7 @@ export class WorkspaceRegistry {
|
||||
blob: string;
|
||||
state: WorkspaceRevision["state"];
|
||||
}> = [];
|
||||
const collectionOwners = new Map<string, string>();
|
||||
try {
|
||||
for (const path of files) {
|
||||
const id = path.slice("workspaces/".length, -".yaml".length);
|
||||
@@ -490,6 +494,12 @@ export class WorkspaceRegistry {
|
||||
? "operational"
|
||||
: "migration_required";
|
||||
if (isCanonicalWorkspace(workspace)) {
|
||||
const collection = workspace.semantic_index.vector_store.collection;
|
||||
const owner = collectionOwners.get(collection);
|
||||
if (owner !== undefined) {
|
||||
throw new Error(`duplicate qdrant collection ownership: ${collection} (${owner}, ${id})`);
|
||||
}
|
||||
collectionOwners.set(collection, id);
|
||||
buildInstallationContract(workspace);
|
||||
renderWorkspaceDocs(workspace);
|
||||
snapshotSource = serializeWorkspaceYaml(workspace);
|
||||
|
||||
Reference in New Issue
Block a user