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.
155 lines
6.4 KiB
TypeScript
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); }
|
|
});
|
|
}
|