Files
ThothII/backend/src/routes/catalog-logical-relationships.ts
T
Codex 076c9742c5 feat: consolidate database management work
Add catalog-owned logical relationships and runtime snapshots, extend the database-management UI and validation coverage, and document the updated operational workflow.

Keep active sensitive-generation status in a tooltip and indicator, and update the layout E2E to follow the history action in its new database-scoped location.
2026-09-01 14:46:55 +02:00

155 lines
6.4 KiB
TypeScript

import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
import {
CatalogLogicalRelationshipService,
LogicalRelationshipColumnNotFoundError,
LogicalRelationshipDatabaseNotFoundError,
LogicalRelationshipDuplicateError,
LogicalRelationshipNotFoundError,
LogicalRelationshipReadOnlyError,
LogicalRelationshipSchemaStaleError,
LogicalRelationshipTargetNotUniqueError,
LogicalRelationshipTypeIncompatibleError,
} from "../catalog/logical-relationship-service.js";
import type { CatalogOperationCoordinator } from "../catalog/operation-coordinator.js";
import { CatalogOperationInProgressError, CatalogUnavailableError } from "../catalog/types.js";
const idSchema = z.uuid();
const createSchema = z.object({
sourceColumnId: idSchema,
targetColumnId: idSchema,
}).strict();
const statusSchema = z.object({ status: z.enum(["active", "excluded"]) }).strict();
const emptySchema = z.object({}).strict();
function manage(request: FastifyRequest, reply: FastifyReply) {
return isPrincipalContext(requirePermission(request, reply, "database.manage"));
}
function safeError(reply: FastifyReply, error: unknown) {
if (error instanceof CatalogUnavailableError) {
return reply.code(503).send({ code: "catalog_unavailable", message: "Database catalog is unavailable." });
}
if (error instanceof CatalogOperationInProgressError) {
return reply.code(409).send({
code: "database_operation_in_progress",
message: "A database operation is already in progress.",
});
}
if (error instanceof LogicalRelationshipDatabaseNotFoundError) {
return reply.code(404).send({ code: "database_not_found", message: "Database configuration was not found." });
}
if (error instanceof LogicalRelationshipColumnNotFoundError) {
return reply.code(404).send({
code: "column_not_found",
message: "Catalog column was not found.",
field: error.field,
});
}
if (error instanceof LogicalRelationshipNotFoundError) {
return reply.code(404).send({ code: "relationship_not_found", message: "Relationship was not found." });
}
if (error instanceof LogicalRelationshipDuplicateError) {
return reply.code(409).send({ code: "relationship_duplicate", message: "Relationship already exists." });
}
if (error instanceof LogicalRelationshipReadOnlyError) {
return reply.code(409).send({ code: "relationship_read_only", message: "Physical relationships are read-only." });
}
if (error instanceof LogicalRelationshipSchemaStaleError) {
return reply.code(409).send({
code: "relationship_schema_stale",
message: "Synchronize the current database schema before managing logical relationships.",
});
}
if (error instanceof LogicalRelationshipTargetNotUniqueError) {
return reply.code(422).send({
code: "relationship_target_not_unique",
message: "Target column must be the only primary-key column of its table.",
field: "targetColumnId",
});
}
if (error instanceof LogicalRelationshipTypeIncompatibleError) {
return reply.code(422).send({
code: "relationship_type_incompatible",
message: "Source and target column types are incompatible.",
field: "targetColumnId",
});
}
if (error instanceof z.ZodError) {
return reply.code(400).send({
code: "relationship_request_invalid",
message: "Relationship request is invalid.",
});
}
return reply.code(500).send({
code: "relationship_operation_failed",
message: "Relationship operation failed.",
});
}
export function catalogLogicalRelationshipRoutes(
app: FastifyInstance,
deps: {
service: CatalogLogicalRelationshipService;
operations: CatalogOperationCoordinator;
},
): void {
app.get("/catalog/databases/:databaseId/relationships", async (request, reply) => {
if (!manage(request, reply)) return reply;
try {
const databaseId = idSchema.parse((request.params as { databaseId?: unknown }).databaseId);
return await deps.service.list(databaseId);
} catch (error) { return safeError(reply, error); }
});
app.post("/catalog/databases/:databaseId/relationships", async (request, reply) => {
if (!manage(request, reply)) return reply;
try {
const databaseId = idSchema.parse((request.params as { databaseId?: unknown }).databaseId);
const input = createSchema.parse(request.body);
const relationship = await deps.operations.run(databaseId, async () => (
await deps.service.addManual(databaseId, input.sourceColumnId, input.targetColumnId)
));
return reply.code(201).send(relationship);
} catch (error) { return safeError(reply, error); }
});
app.post("/catalog/databases/:databaseId/relationships/rebuild-generated", async (request, reply) => {
if (!manage(request, reply)) return reply;
try {
const databaseId = idSchema.parse((request.params as { databaseId?: unknown }).databaseId);
emptySchema.parse(request.body ?? {});
return await deps.operations.run(databaseId, async () => (
await deps.service.rebuildGenerated(databaseId)
));
} catch (error) { return safeError(reply, error); }
});
app.patch("/catalog/databases/:databaseId/relationships/:relationshipId", async (request, reply) => {
if (!manage(request, reply)) return reply;
try {
const params = request.params as { databaseId?: unknown; relationshipId?: unknown };
const databaseId = idSchema.parse(params.databaseId);
const relationshipId = idSchema.parse(params.relationshipId);
const input = statusSchema.parse(request.body);
return await deps.operations.run(databaseId, async () => (
await deps.service.setStatus(databaseId, relationshipId, input.status)
));
} catch (error) { return safeError(reply, error); }
});
app.delete("/catalog/databases/:databaseId/relationships/:relationshipId", async (request, reply) => {
if (!manage(request, reply)) return reply;
try {
const params = request.params as { databaseId?: unknown; relationshipId?: unknown };
const databaseId = idSchema.parse(params.databaseId);
const relationshipId = idSchema.parse(params.relationshipId);
await deps.operations.run(databaseId, async () => {
await deps.service.deletePermanently(databaseId, relationshipId);
});
return reply.code(204).send();
} catch (error) { return safeError(reply, error); }
});
}