Add named project snapshots

This commit is contained in:
2026-07-25 22:15:22 +02:00
parent 5a0c9019af
commit 7c670fece3
21 changed files with 3076 additions and 7 deletions
@@ -0,0 +1,16 @@
CREATE TABLE `project_snapshots` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_revision` integer NOT NULL,
`schema_version` integer NOT NULL,
`name` text NOT NULL,
`description` text,
`payload_json` text NOT NULL,
`payload_sha256` text NOT NULL,
`created_at_iso` text NOT NULL,
`created_by_actor_id` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `project_snapshots_project_created_idx` ON `project_snapshots` (`project_id`,`created_at_iso`);--> statement-breakpoint
CREATE UNIQUE INDEX `project_snapshots_project_name_unique` ON `project_snapshots` (`project_id`,`name`);
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -99,6 +99,13 @@
"when": 1784835703230,
"tag": "0013_project_history_stacks",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1785009799979,
"tag": "0014_project_snapshots",
"breakpoints": true
}
]
}
}
@@ -0,0 +1,314 @@
import crypto from "node:crypto";
import { and, asc, desc, eq } from "drizzle-orm";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
import {
parseProjectStateSnapshot,
projectStateSnapshotSchemaVersion,
serializeProjectStateSnapshot,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
CreateNamedProjectSnapshotInput,
ProjectSnapshotMetadata,
ProjectSnapshotStore,
} from "../../domain/ports/project-snapshot.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projectSnapshots } from "../schema/project-snapshots.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
export class ProjectSnapshotRepository implements ProjectSnapshotStore {
constructor(private readonly database: AppDatabase) {}
createNamed(
input: CreateNamedProjectSnapshotInput
): ProjectSnapshotMetadata | null {
validateCreateInput(input);
const snapshotName = input.name.trim();
const snapshotDescription =
input.description?.trim() || null;
return this.database.transaction((tx) => {
const project = tx
.select({
id: projects.id,
name: projects.name,
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
currentRevision: projects.currentRevision,
})
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
return null;
}
if (project.currentRevision !== input.expectedRevision) {
throw new ProjectRevisionConflictError(
input.projectId,
input.expectedRevision,
project.currentRevision
);
}
const existing = tx
.select({ id: projectSnapshots.id })
.from(projectSnapshots)
.where(
and(
eq(projectSnapshots.projectId, input.projectId),
eq(projectSnapshots.name, snapshotName)
)
)
.get();
if (existing) {
throw new ProjectSnapshotNameConflictError(
input.projectId,
snapshotName
);
}
const boardRows = tx
.select()
.from(distributionBoards)
.where(eq(distributionBoards.projectId, input.projectId))
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
.all();
const listRows = tx
.select()
.from(circuitLists)
.where(eq(circuitLists.projectId, input.projectId))
.orderBy(asc(circuitLists.name), asc(circuitLists.id))
.all();
const sectionRows = tx
.select({
id: circuitSections.id,
circuitListId: circuitSections.circuitListId,
key: circuitSections.key,
displayName: circuitSections.displayName,
prefix: circuitSections.prefix,
sortOrder: circuitSections.sortOrder,
})
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(eq(circuitLists.projectId, input.projectId))
.orderBy(
asc(circuitSections.circuitListId),
asc(circuitSections.sortOrder),
asc(circuitSections.id)
)
.all();
const circuitRows = tx
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
sectionId: circuits.sectionId,
equipmentIdentifier: circuits.equipmentIdentifier,
displayName: circuits.displayName,
sortOrder: circuits.sortOrder,
protectionType: circuits.protectionType,
protectionRatedCurrent: circuits.protectionRatedCurrent,
protectionCharacteristic: circuits.protectionCharacteristic,
cableType: circuits.cableType,
cableCrossSection: circuits.cableCrossSection,
cableLength: circuits.cableLength,
rcdAssignment: circuits.rcdAssignment,
terminalDesignation: circuits.terminalDesignation,
voltage: circuits.voltage,
controlRequirement: circuits.controlRequirement,
status: circuits.status,
isReserve: circuits.isReserve,
remark: circuits.remark,
})
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(eq(circuitLists.projectId, input.projectId))
.orderBy(
asc(circuits.circuitListId),
asc(circuits.sortOrder),
asc(circuits.id)
)
.all();
const deviceRowRows = tx
.select({
id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId,
linkedProjectDeviceId:
circuitDeviceRows.linkedProjectDeviceId,
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
sortOrder: circuitDeviceRows.sortOrder,
name: circuitDeviceRows.name,
displayName: circuitDeviceRows.displayName,
phaseType: circuitDeviceRows.phaseType,
connectionKind: circuitDeviceRows.connectionKind,
costGroup: circuitDeviceRows.costGroup,
category: circuitDeviceRows.category,
level: circuitDeviceRows.level,
roomId: circuitDeviceRows.roomId,
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
quantity: circuitDeviceRows.quantity,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor:
circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi,
remark: circuitDeviceRows.remark,
overriddenFields: circuitDeviceRows.overriddenFields,
})
.from(circuitDeviceRows)
.innerJoin(
circuits,
eq(circuits.id, circuitDeviceRows.circuitId)
)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(eq(circuitLists.projectId, input.projectId))
.orderBy(
asc(circuitDeviceRows.circuitId),
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
const projectDeviceRows = tx
.select()
.from(projectDevices)
.where(eq(projectDevices.projectId, input.projectId))
.orderBy(
asc(projectDevices.displayName),
asc(projectDevices.id)
)
.all();
const floorRows = tx
.select()
.from(floors)
.where(eq(floors.projectId, input.projectId))
.orderBy(asc(floors.sortOrder), asc(floors.id))
.all();
const roomRows = tx
.select()
.from(rooms)
.where(eq(rooms.projectId, input.projectId))
.orderBy(asc(rooms.roomNumber), asc(rooms.id))
.all();
const deviceRowsByCircuit = new Map<string, typeof deviceRowRows>();
for (const row of deviceRowRows) {
const entries = deviceRowsByCircuit.get(row.circuitId) ?? [];
entries.push(row);
deviceRowsByCircuit.set(row.circuitId, entries);
}
const snapshot = parseProjectStateSnapshot({
schemaVersion: projectStateSnapshotSchemaVersion,
project: {
id: project.id,
name: project.name,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
},
distributionBoards: boardRows,
circuitLists: listRows,
circuitSections: sectionRows,
circuits: circuitRows.map((circuit) => ({
...circuit,
isReserve: Boolean(circuit.isReserve),
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
})),
projectDevices: projectDeviceRows,
floors: floorRows,
rooms: roomRows,
});
const payloadJson = serializeProjectStateSnapshot(snapshot);
const metadata: ProjectSnapshotMetadata = {
id: crypto.randomUUID(),
projectId: input.projectId,
sourceRevision: project.currentRevision,
schemaVersion: projectStateSnapshotSchemaVersion,
name: snapshotName,
description: snapshotDescription,
payloadSha256: crypto
.createHash("sha256")
.update(payloadJson)
.digest("hex"),
createdAtIso: new Date().toISOString(),
createdByActorId: input.actorId ?? null,
};
tx.insert(projectSnapshots)
.values({
...metadata,
payloadJson,
})
.run();
return metadata;
});
}
listByProject(projectId: string): ProjectSnapshotMetadata[] | null {
const project = this.database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
return null;
}
return this.database
.select({
id: projectSnapshots.id,
projectId: projectSnapshots.projectId,
sourceRevision: projectSnapshots.sourceRevision,
schemaVersion: projectSnapshots.schemaVersion,
name: projectSnapshots.name,
description: projectSnapshots.description,
payloadSha256: projectSnapshots.payloadSha256,
createdAtIso: projectSnapshots.createdAtIso,
createdByActorId: projectSnapshots.createdByActorId,
})
.from(projectSnapshots)
.where(eq(projectSnapshots.projectId, projectId))
.orderBy(
desc(projectSnapshots.createdAtIso),
desc(projectSnapshots.id)
)
.all();
}
}
function validateCreateInput(input: CreateNamedProjectSnapshotInput) {
if (
!Number.isSafeInteger(input.expectedRevision) ||
input.expectedRevision < 0
) {
throw new Error(
"Expected project revision must be a non-negative integer."
);
}
if (!input.name.trim()) {
throw new Error("Project snapshot name is required.");
}
if (input.name.trim().length > 100) {
throw new Error(
"Project snapshot name must not exceed 100 characters."
);
}
if (
input.description !== undefined &&
input.description.trim().length > 500
) {
throw new Error(
"Project snapshot description must not exceed 500 characters."
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import {
index,
integer,
sqliteTable,
text,
unique,
} from "drizzle-orm/sqlite-core";
import { projects } from "./projects.js";
export const projectSnapshots = sqliteTable(
"project_snapshots",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
sourceRevision: integer("source_revision").notNull(),
schemaVersion: integer("schema_version").notNull(),
name: text("name").notNull(),
description: text("description"),
payloadJson: text("payload_json").notNull(),
payloadSha256: text("payload_sha256").notNull(),
createdAtIso: text("created_at_iso").notNull(),
createdByActorId: text("created_by_actor_id"),
},
(table) => [
unique("project_snapshots_project_name_unique").on(
table.projectId,
table.name
),
index("project_snapshots_project_created_idx").on(
table.projectId,
table.createdAtIso
),
]
);
@@ -0,0 +1,11 @@
export class ProjectSnapshotNameConflictError extends Error {
constructor(
readonly projectId: string,
readonly snapshotName: string
) {
super(
`Project ${projectId} already has a snapshot named "${snapshotName}".`
);
this.name = "ProjectSnapshotNameConflictError";
}
}
@@ -0,0 +1,306 @@
import { z } from "zod";
export const projectStateSnapshotSchemaVersion = 1 as const;
const idSchema = z.string().trim().min(1);
const nullableStringSchema = z.string().nullable();
const finiteNumberSchema = z.number().finite();
const projectSchema = z
.object({
id: idSchema,
name: z.string().trim().min(1),
singlePhaseVoltageV: finiteNumberSchema.positive(),
threePhaseVoltageV: finiteNumberSchema.positive(),
})
.strict();
const distributionBoardSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
})
.strict();
const circuitListSchema = z
.object({
id: idSchema,
projectId: idSchema,
distributionBoardId: idSchema,
name: z.string().trim().min(1),
})
.strict();
const circuitSectionSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
key: z.string().trim().min(1),
displayName: z.string().trim().min(1),
prefix: z.string().trim().min(1),
sortOrder: finiteNumberSchema,
})
.strict();
const circuitDeviceRowSchema = z
.object({
id: idSchema,
circuitId: idSchema,
linkedProjectDeviceId: idSchema.nullable(),
legacyConsumerId: z.string().nullable(),
sortOrder: finiteNumberSchema,
name: z.string().trim().min(1),
displayName: z.string().trim().min(1),
phaseType: nullableStringSchema,
connectionKind: nullableStringSchema,
costGroup: nullableStringSchema,
category: nullableStringSchema,
level: nullableStringSchema,
roomId: idSchema.nullable(),
roomNumberSnapshot: nullableStringSchema,
roomNameSnapshot: nullableStringSchema,
quantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.nonnegative(),
cosPhi: finiteNumberSchema.positive().nullable(),
remark: nullableStringSchema,
overriddenFields: nullableStringSchema,
})
.strict();
const circuitSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
sectionId: idSchema,
equipmentIdentifier: z.string().trim().min(1),
displayName: nullableStringSchema,
sortOrder: finiteNumberSchema,
protectionType: nullableStringSchema,
protectionRatedCurrent: finiteNumberSchema.nonnegative().nullable(),
protectionCharacteristic: nullableStringSchema,
cableType: nullableStringSchema,
cableCrossSection: nullableStringSchema,
cableLength: finiteNumberSchema.nonnegative().nullable(),
rcdAssignment: nullableStringSchema,
terminalDesignation: nullableStringSchema,
voltage: finiteNumberSchema.positive().nullable(),
controlRequirement: nullableStringSchema,
status: nullableStringSchema,
isReserve: z.boolean(),
remark: nullableStringSchema,
deviceRows: z.array(circuitDeviceRowSchema),
})
.strict();
const projectDeviceSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
displayName: z.string().trim().min(1),
phaseType: z.enum(["single_phase", "three_phase"]),
connectionKind: nullableStringSchema,
costGroup: nullableStringSchema,
category: nullableStringSchema,
quantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.min(0).max(1),
cosPhi: finiteNumberSchema.min(0).max(1).nullable(),
remark: nullableStringSchema,
voltageV: finiteNumberSchema.positive().nullable(),
})
.strict();
const floorSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
sortOrder: finiteNumberSchema,
})
.strict();
const roomSchema = z
.object({
id: idSchema,
projectId: idSchema,
floorId: idSchema.nullable(),
roomNumber: z.string().trim().min(1),
roomName: z.string().trim().min(1),
})
.strict();
export const projectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
circuits: z.array(circuitSchema),
projectDevices: z.array(projectDeviceSchema),
floors: z.array(floorSchema),
rooms: z.array(roomSchema),
})
.strict();
export type ProjectStateSnapshot = z.infer<
typeof projectStateSnapshotSchema
>;
export function parseProjectStateSnapshot(
value: unknown
): ProjectStateSnapshot {
const snapshot = projectStateSnapshotSchema.parse(value);
assertProjectStateSnapshotRelations(snapshot);
return snapshot;
}
export function serializeProjectStateSnapshot(
snapshot: ProjectStateSnapshot
): string {
return JSON.stringify(parseProjectStateSnapshot(snapshot));
}
export function deserializeProjectStateSnapshot(
serialized: string
): ProjectStateSnapshot {
return parseProjectStateSnapshot(JSON.parse(serialized) as unknown);
}
function assertProjectStateSnapshotRelations(
snapshot: ProjectStateSnapshot
) {
const projectId = snapshot.project.id;
const boardIds = uniqueIds(
snapshot.distributionBoards,
"distribution board"
);
const circuitListIds = uniqueIds(
snapshot.circuitLists,
"circuit list"
);
const sectionIds = uniqueIds(
snapshot.circuitSections,
"circuit section"
);
const projectDeviceIds = uniqueIds(
snapshot.projectDevices,
"project device"
);
const floorIds = uniqueIds(snapshot.floors, "floor");
const roomIds = uniqueIds(snapshot.rooms, "room");
uniqueIds(snapshot.circuits, "circuit");
const sectionById = new Map(
snapshot.circuitSections.map((entry) => [entry.id, entry])
);
for (const board of snapshot.distributionBoards) {
assertProjectOwnership(board.projectId, projectId, "distribution board");
}
for (const list of snapshot.circuitLists) {
assertProjectOwnership(list.projectId, projectId, "circuit list");
assertReference(
boardIds,
list.distributionBoardId,
"circuit list distribution board"
);
}
for (const section of snapshot.circuitSections) {
assertReference(
circuitListIds,
section.circuitListId,
"circuit section list"
);
}
for (const device of snapshot.projectDevices) {
assertProjectOwnership(device.projectId, projectId, "project device");
}
for (const floor of snapshot.floors) {
assertProjectOwnership(floor.projectId, projectId, "floor");
}
for (const room of snapshot.rooms) {
assertProjectOwnership(room.projectId, projectId, "room");
if (room.floorId !== null) {
assertReference(floorIds, room.floorId, "room floor");
}
}
const deviceRowIds = new Set<string>();
for (const circuit of snapshot.circuits) {
assertReference(
circuitListIds,
circuit.circuitListId,
"circuit list"
);
assertReference(sectionIds, circuit.sectionId, "circuit section");
const section = sectionById.get(circuit.sectionId)!;
if (section.circuitListId !== circuit.circuitListId) {
throw new Error(
"Snapshot circuit section belongs to a different circuit list."
);
}
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
throw new Error(
"Snapshot circuit reserve state must match its device rows."
);
}
for (const row of circuit.deviceRows) {
if (row.circuitId !== circuit.id) {
throw new Error(
"Snapshot device row belongs to a different circuit."
);
}
if (deviceRowIds.has(row.id)) {
throw new Error("Snapshot contains duplicate device row ids.");
}
deviceRowIds.add(row.id);
if (row.linkedProjectDeviceId !== null) {
assertReference(
projectDeviceIds,
row.linkedProjectDeviceId,
"device row project device"
);
}
if (row.roomId !== null) {
assertReference(roomIds, row.roomId, "device row room");
}
}
}
}
function uniqueIds(
entries: ReadonlyArray<{ id: string }>,
label: string
) {
const ids = new Set<string>();
for (const entry of entries) {
if (ids.has(entry.id)) {
throw new Error(`Snapshot contains duplicate ${label} ids.`);
}
ids.add(entry.id);
}
return ids;
}
function assertProjectOwnership(
actualProjectId: string,
expectedProjectId: string,
label: string
) {
if (actualProjectId !== expectedProjectId) {
throw new Error(`Snapshot ${label} belongs to a different project.`);
}
}
function assertReference(
ids: ReadonlySet<string>,
id: string,
label: string
) {
if (!ids.has(id)) {
throw new Error(`Snapshot ${label} reference is invalid.`);
}
}
@@ -0,0 +1,26 @@
export interface ProjectSnapshotMetadata {
id: string;
projectId: string;
sourceRevision: number;
schemaVersion: number;
name: string;
description: string | null;
payloadSha256: string;
createdAtIso: string;
createdByActorId: string | null;
}
export interface CreateNamedProjectSnapshotInput {
projectId: string;
expectedRevision: number;
name: string;
description?: string;
actorId?: string;
}
export interface ProjectSnapshotStore {
createNamed(
input: CreateNamedProjectSnapshotInput
): ProjectSnapshotMetadata | null;
listByProject(projectId: string): ProjectSnapshotMetadata[] | null;
}
@@ -0,0 +1,5 @@
import { db } from "../../db/client.js";
import { ProjectSnapshotRepository } from "../../db/repositories/project-snapshot.repository.js";
export const projectSnapshotStore =
new ProjectSnapshotRepository(db);
@@ -0,0 +1,69 @@
import type { NextFunction, Request, Response } from "express";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
import { createNamedProjectSnapshotSchema } from "../../shared/validation/project-snapshot.schemas.js";
import { projectSnapshotStore } from "../composition/project-snapshot-store.js";
export function listProjectSnapshots(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const snapshots = projectSnapshotStore.listByProject(projectId);
if (!snapshots) {
return res.status(404).json({ error: "Project not found" });
}
return res.json(snapshots);
}
export function createNamedProjectSnapshot(
req: Request,
res: Response,
next: NextFunction
) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const parsed = createNamedProjectSnapshotSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const snapshot = projectSnapshotStore.createNamed({
projectId,
...parsed.data,
});
if (!snapshot) {
return res.status(404).json({ error: "Project not found" });
}
return res.status(201).json(snapshot);
} catch (error) {
if (error instanceof ProjectRevisionConflictError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_REVISION_CONFLICT",
expectedRevision: error.expectedRevision,
currentRevision: error.actualRevision,
});
}
if (error instanceof ProjectSnapshotNameConflictError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_SNAPSHOT_NAME_CONFLICT",
snapshotName: error.snapshotName,
});
}
return next(error);
}
}
function getProjectId(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
res.status(400).json({ error: "Invalid projectId" });
return null;
}
return projectId;
}
+6
View File
@@ -22,6 +22,10 @@ import {
redoProjectCommand,
undoProjectCommand,
} from "../controllers/project-command.controller.js";
import {
createNamedProjectSnapshot,
listProjectSnapshots,
} from "../controllers/project-snapshot.controller.js";
export const projectRouter = Router();
@@ -33,6 +37,8 @@ projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand);
projectRouter.post("/:projectId/history/undo", undoProjectCommand);
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
projectRouter.get("/:projectId/snapshots", listProjectSnapshots);
projectRouter.post("/:projectId/snapshots", createNamedProjectSnapshot);
projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
@@ -0,0 +1,10 @@
import { z } from "zod";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
export const createNamedProjectSnapshotSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(100),
description: z.string().trim().max(500).optional(),
})
.strict();