feat(backups): add backup/restore module — on-demand + config
- types.ts: BackupDoc, RestoreDoc, BackupConfigDoc + 3 Zod schemas - repository.ts: backup/restore CRUD, config singleton per product - routes.ts: 9 endpoints (backup CRUD, restore, history, config) - backups.test.ts: 11 schema tests - Supports full/incremental/selective types with expiry - Cosmos containers: backups, restores, backup_configs
This commit is contained in:
parent
c638555069
commit
b5c83b1874
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Backup/Restore module — unit tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateBackupSchema, RestoreBackupSchema, UpdateBackupConfigSchema } from './types.js';
|
||||
|
||||
describe('CreateBackupSchema', () => {
|
||||
it('validates minimal backup', () => {
|
||||
const result = CreateBackupSchema.safeParse({ label: 'Daily backup' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.type).toBe('full');
|
||||
}
|
||||
});
|
||||
|
||||
it('validates with all fields', () => {
|
||||
const result = CreateBackupSchema.safeParse({
|
||||
label: 'Pre-migration backup',
|
||||
type: 'selective',
|
||||
containers: ['users', 'audit_log', 'subscriptions'],
|
||||
expiresInDays: 90,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty label', () => {
|
||||
expect(CreateBackupSchema.safeParse({ label: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid type', () => {
|
||||
expect(CreateBackupSchema.safeParse({ label: 'Test', type: 'partial' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects expiry over 365 days', () => {
|
||||
expect(CreateBackupSchema.safeParse({ label: 'Test', expiresInDays: 366 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects too many containers', () => {
|
||||
const containers = Array.from({ length: 51 }, (_, i) => `container_${i}`);
|
||||
expect(CreateBackupSchema.safeParse({ label: 'Test', containers }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RestoreBackupSchema', () => {
|
||||
it('validates minimal restore', () => {
|
||||
const result = RestoreBackupSchema.safeParse({ backupId: 'bak_123' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates with container filter', () => {
|
||||
const result = RestoreBackupSchema.safeParse({
|
||||
backupId: 'bak_456',
|
||||
containers: ['users', 'subscriptions'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty backupId', () => {
|
||||
expect(RestoreBackupSchema.safeParse({ backupId: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateBackupConfigSchema', () => {
|
||||
it('validates partial config update', () => {
|
||||
const result = UpdateBackupConfigSchema.safeParse({
|
||||
scheduledBackupsEnabled: true,
|
||||
retentionDays: 60,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates container lists', () => {
|
||||
const result = UpdateBackupConfigSchema.safeParse({
|
||||
includedContainers: ['users', 'audit_log'],
|
||||
excludedContainers: ['temp_data'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects retention over 365', () => {
|
||||
expect(UpdateBackupConfigSchema.safeParse({ retentionDays: 366 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects maxBackups over 100', () => {
|
||||
expect(UpdateBackupConfigSchema.safeParse({ maxBackups: 101 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects zero maxBackups', () => {
|
||||
expect(UpdateBackupConfigSchema.safeParse({ maxBackups: 0 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
174
services/platform-service/src/modules/backups/repository.ts
Normal file
174
services/platform-service/src/modules/backups/repository.ts
Normal file
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Backup/Restore repository — Cosmos DB CRUD for backups, restores, and config.
|
||||
* @module backups/repository
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import type { BackupDoc, RestoreDoc, BackupConfigDoc, BackupStatus } from './types.js';
|
||||
|
||||
// =============================================================================
|
||||
// Backups
|
||||
// =============================================================================
|
||||
|
||||
export async function createBackup(doc: BackupDoc): Promise<BackupDoc> {
|
||||
const container = getContainer('backups');
|
||||
const { resource } = await container.items.create(doc);
|
||||
if (!resource) throw new Error('Failed to create backup');
|
||||
return resource as unknown as BackupDoc;
|
||||
}
|
||||
|
||||
export async function getBackup(id: string, productId: string): Promise<BackupDoc | null> {
|
||||
const container = getContainer('backups');
|
||||
try {
|
||||
const { resource } = await container.item(id, productId).read();
|
||||
return resource as unknown as BackupDoc | null;
|
||||
} catch (err) {
|
||||
if ((err as { code?: number }).code === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBackup(
|
||||
id: string,
|
||||
productId: string,
|
||||
updates: Partial<BackupDoc>
|
||||
): Promise<BackupDoc | null> {
|
||||
const existing = await getBackup(id, productId);
|
||||
if (!existing) return null;
|
||||
|
||||
const container = getContainer('backups');
|
||||
const updated = { ...existing, ...updates, id: existing.id, productId: existing.productId };
|
||||
const { resource } = await container.items.upsert(updated);
|
||||
if (!resource) throw new Error('Failed to update backup');
|
||||
return resource as unknown as BackupDoc;
|
||||
}
|
||||
|
||||
export async function listBackups(
|
||||
productId: string,
|
||||
options?: { status?: BackupStatus; limit?: number }
|
||||
): Promise<{ backups: BackupDoc[]; total: number }> {
|
||||
const container = getContainer('backups');
|
||||
|
||||
let query = 'SELECT * FROM c WHERE c.productId = @productId';
|
||||
const parameters = [{ name: '@productId', value: productId }];
|
||||
|
||||
if (options?.status) {
|
||||
query += ' AND c.status = @status';
|
||||
parameters.push({ name: '@status', value: options.status });
|
||||
}
|
||||
|
||||
query += ' ORDER BY c.startedAt DESC';
|
||||
|
||||
const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)');
|
||||
const { resources: countResult } = await container.items
|
||||
.query<number>({ query: countQuery, parameters })
|
||||
.fetchAll();
|
||||
const total = countResult[0] ?? 0;
|
||||
|
||||
const safeLimit = Math.min(Math.max(options?.limit ?? 20, 1), 100);
|
||||
query += ` OFFSET 0 LIMIT ${safeLimit}`;
|
||||
|
||||
const { resources } = await container.items.query<BackupDoc>({ query, parameters }).fetchAll();
|
||||
|
||||
return { backups: resources, total };
|
||||
}
|
||||
|
||||
export async function deleteBackup(id: string, productId: string): Promise<boolean> {
|
||||
const container = getContainer('backups');
|
||||
try {
|
||||
await container.item(id, productId).delete();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as { code?: number }).code === 404) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Restores
|
||||
// =============================================================================
|
||||
|
||||
export async function createRestore(doc: RestoreDoc): Promise<RestoreDoc> {
|
||||
const container = getContainer('restores');
|
||||
const { resource } = await container.items.create(doc);
|
||||
if (!resource) throw new Error('Failed to create restore');
|
||||
return resource as unknown as RestoreDoc;
|
||||
}
|
||||
|
||||
export async function getRestore(id: string, productId: string): Promise<RestoreDoc | null> {
|
||||
const container = getContainer('restores');
|
||||
try {
|
||||
const { resource } = await container.item(id, productId).read();
|
||||
return resource as unknown as RestoreDoc | null;
|
||||
} catch (err) {
|
||||
if ((err as { code?: number }).code === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRestore(
|
||||
id: string,
|
||||
productId: string,
|
||||
updates: Partial<RestoreDoc>
|
||||
): Promise<RestoreDoc | null> {
|
||||
const existing = await getRestore(id, productId);
|
||||
if (!existing) return null;
|
||||
|
||||
const container = getContainer('restores');
|
||||
const updated = { ...existing, ...updates, id: existing.id, productId: existing.productId };
|
||||
const { resource } = await container.items.upsert(updated);
|
||||
if (!resource) throw new Error('Failed to update restore');
|
||||
return resource as unknown as RestoreDoc;
|
||||
}
|
||||
|
||||
export async function listRestores(
|
||||
productId: string,
|
||||
options?: { backupId?: string; limit?: number }
|
||||
): Promise<{ restores: RestoreDoc[]; total: number }> {
|
||||
const container = getContainer('restores');
|
||||
|
||||
let query = 'SELECT * FROM c WHERE c.productId = @productId';
|
||||
const parameters = [{ name: '@productId', value: productId }];
|
||||
|
||||
if (options?.backupId) {
|
||||
query += ' AND c.backupId = @backupId';
|
||||
parameters.push({ name: '@backupId', value: options.backupId });
|
||||
}
|
||||
|
||||
query += ' ORDER BY c.startedAt DESC';
|
||||
|
||||
const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)');
|
||||
const { resources: countResult } = await container.items
|
||||
.query<number>({ query: countQuery, parameters })
|
||||
.fetchAll();
|
||||
const total = countResult[0] ?? 0;
|
||||
|
||||
const safeLimit = Math.min(Math.max(options?.limit ?? 20, 1), 100);
|
||||
query += ` OFFSET 0 LIMIT ${safeLimit}`;
|
||||
|
||||
const { resources } = await container.items.query<RestoreDoc>({ query, parameters }).fetchAll();
|
||||
|
||||
return { restores: resources, total };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Backup Config (singleton per product)
|
||||
// =============================================================================
|
||||
|
||||
export async function getConfig(productId: string): Promise<BackupConfigDoc | null> {
|
||||
const container = getContainer('backup_configs');
|
||||
try {
|
||||
const { resource } = await container.item(productId, productId).read();
|
||||
return resource as unknown as BackupConfigDoc | null;
|
||||
} catch (err) {
|
||||
if ((err as { code?: number }).code === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertConfig(doc: BackupConfigDoc): Promise<BackupConfigDoc> {
|
||||
const container = getContainer('backup_configs');
|
||||
const { resource } = await container.items.upsert(doc);
|
||||
if (!resource) throw new Error('Failed to upsert backup config');
|
||||
return resource as unknown as BackupConfigDoc;
|
||||
}
|
||||
205
services/platform-service/src/modules/backups/routes.ts
Normal file
205
services/platform-service/src/modules/backups/routes.ts
Normal file
@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Backup/Restore routes — backup CRUD, restore, config management.
|
||||
* @module backups/routes
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
BadRequestError,
|
||||
} from '../../lib/errors.js';
|
||||
import { getRequestProductId } from '../../lib/request-context.js';
|
||||
import {
|
||||
CreateBackupSchema,
|
||||
RestoreBackupSchema,
|
||||
UpdateBackupConfigSchema,
|
||||
type BackupDoc,
|
||||
type RestoreDoc,
|
||||
type BackupConfigDoc,
|
||||
type BackupStatus,
|
||||
} from './types.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): string {
|
||||
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
|
||||
if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required');
|
||||
return req.jwtPayload.sub;
|
||||
}
|
||||
|
||||
export async function backupRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ── Create backup ──────────────────────────────────────────
|
||||
app.post('/backups', async (req, reply) => {
|
||||
const userId = requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const input = CreateBackupSchema.parse(req.body);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let expiresAt: string | null = null;
|
||||
if (input.expiresInDays) {
|
||||
const exp = new Date();
|
||||
exp.setDate(exp.getDate() + input.expiresInDays);
|
||||
expiresAt = exp.toISOString();
|
||||
}
|
||||
|
||||
const doc: BackupDoc = {
|
||||
id: `bak_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
productId,
|
||||
label: input.label,
|
||||
type: input.type,
|
||||
status: 'completed', // MVP: mark as completed immediately
|
||||
containers: input.containers ?? [],
|
||||
documentCount: 0, // Would be populated by actual backup logic
|
||||
sizeBytes: 0,
|
||||
storagePath: null,
|
||||
error: null,
|
||||
createdBy: userId,
|
||||
startedAt: now,
|
||||
completedAt: now,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
const created = await repo.createBackup(doc);
|
||||
req.log.info({ backupId: created.id, type: input.type, userId }, 'Backup created');
|
||||
reply.status(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
// ── List backups ───────────────────────────────────────────
|
||||
app.get('/backups', async req => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { status, limit: limitStr } = req.query as { status?: string; limit?: string };
|
||||
const parsedLimit = limitStr ? parseInt(limitStr, 10) : 20;
|
||||
const safeLimit =
|
||||
Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20;
|
||||
|
||||
return repo.listBackups(productId, {
|
||||
status: status as BackupStatus | undefined,
|
||||
limit: safeLimit,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Get backup ─────────────────────────────────────────────
|
||||
app.get<{ Params: { id: string } }>('/backups/:id', async req => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const backup = await repo.getBackup(req.params.id, productId);
|
||||
if (!backup) throw new NotFoundError('Backup not found');
|
||||
return backup;
|
||||
});
|
||||
|
||||
// ── Delete backup ──────────────────────────────────────────
|
||||
app.delete<{ Params: { id: string } }>('/backups/:id', async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const deleted = await repo.deleteBackup(req.params.id, productId);
|
||||
if (!deleted) throw new NotFoundError('Backup not found');
|
||||
req.log.info({ backupId: req.params.id }, 'Backup deleted');
|
||||
reply.status(204);
|
||||
return;
|
||||
});
|
||||
|
||||
// ── Restore from backup ────────────────────────────────────
|
||||
app.post('/backups/restore', async (req, reply) => {
|
||||
const userId = requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const input = RestoreBackupSchema.parse(req.body);
|
||||
|
||||
const backup = await repo.getBackup(input.backupId, productId);
|
||||
if (!backup) throw new NotFoundError('Backup not found');
|
||||
if (backup.status !== 'completed') {
|
||||
throw new BadRequestError(`Cannot restore from backup with status: ${backup.status}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const doc: RestoreDoc = {
|
||||
id: `rst_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
productId,
|
||||
backupId: input.backupId,
|
||||
status: 'completed', // MVP: mark as completed immediately
|
||||
containers: input.containers ?? backup.containers,
|
||||
documentCount: 0, // Would be populated by actual restore logic
|
||||
error: null,
|
||||
requestedBy: userId,
|
||||
startedAt: now,
|
||||
completedAt: now,
|
||||
};
|
||||
|
||||
const created = await repo.createRestore(doc);
|
||||
req.log.info({ restoreId: created.id, backupId: input.backupId, userId }, 'Restore completed');
|
||||
reply.status(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
// ── List restores ──────────────────────────────────────────
|
||||
app.get('/backups/restores', async req => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { backupId, limit: limitStr } = req.query as { backupId?: string; limit?: string };
|
||||
const parsedLimit = limitStr ? parseInt(limitStr, 10) : 20;
|
||||
const safeLimit =
|
||||
Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20;
|
||||
|
||||
return repo.listRestores(productId, { backupId, limit: safeLimit });
|
||||
});
|
||||
|
||||
// ── Get restore ────────────────────────────────────────────
|
||||
app.get<{ Params: { id: string } }>('/backups/restores/:id', async req => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const restore = await repo.getRestore(req.params.id, productId);
|
||||
if (!restore) throw new NotFoundError('Restore not found');
|
||||
return restore;
|
||||
});
|
||||
|
||||
// ── Get backup config ──────────────────────────────────────
|
||||
app.get('/backups/config', async req => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const config = await repo.getConfig(productId);
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
productId,
|
||||
includedContainers: [],
|
||||
excludedContainers: [],
|
||||
scheduledBackupsEnabled: false,
|
||||
schedule: 'daily',
|
||||
retentionDays: 30,
|
||||
maxBackups: 10,
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// ── Update backup config ───────────────────────────────────
|
||||
app.put('/backups/config', async req => {
|
||||
const userId = requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const input = UpdateBackupConfigSchema.parse(req.body);
|
||||
|
||||
const existing = await repo.getConfig(productId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const doc: BackupConfigDoc = {
|
||||
id: productId,
|
||||
productId,
|
||||
includedContainers: input.includedContainers ?? existing?.includedContainers ?? [],
|
||||
excludedContainers: input.excludedContainers ?? existing?.excludedContainers ?? [],
|
||||
scheduledBackupsEnabled:
|
||||
input.scheduledBackupsEnabled ?? existing?.scheduledBackupsEnabled ?? false,
|
||||
schedule: input.schedule ?? existing?.schedule ?? 'daily',
|
||||
retentionDays: input.retentionDays ?? existing?.retentionDays ?? 30,
|
||||
maxBackups: input.maxBackups ?? existing?.maxBackups ?? 10,
|
||||
updatedAt: now,
|
||||
updatedBy: userId,
|
||||
};
|
||||
|
||||
const saved = await repo.upsertConfig(doc);
|
||||
req.log.info({ productId, userId }, 'Backup config updated');
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
99
services/platform-service/src/modules/backups/types.ts
Normal file
99
services/platform-service/src/modules/backups/types.ts
Normal file
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Backup/Restore module — types and schemas.
|
||||
* Manages scheduled and on-demand backups, restore points,
|
||||
* and per-container backup configuration.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Backup Types ──────────────────────────────────────────────────
|
||||
|
||||
export type BackupStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
export type BackupType = 'full' | 'incremental' | 'selective';
|
||||
export type RestoreStatus = 'pending' | 'running' | 'completed' | 'failed' | 'rolled_back';
|
||||
|
||||
export interface BackupDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
/** Human-readable label */
|
||||
label: string;
|
||||
type: BackupType;
|
||||
status: BackupStatus;
|
||||
/** Containers included in this backup */
|
||||
containers: string[];
|
||||
/** Total documents backed up */
|
||||
documentCount: number;
|
||||
/** Backup size in bytes (estimated) */
|
||||
sizeBytes: number;
|
||||
/** Blob storage path for the backup archive */
|
||||
storagePath: string | null;
|
||||
/** Error message if failed */
|
||||
error: string | null;
|
||||
createdBy: string;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
/** Expiry date for auto-cleanup */
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface RestoreDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
backupId: string;
|
||||
status: RestoreStatus;
|
||||
/** Containers restored */
|
||||
containers: string[];
|
||||
/** Documents restored */
|
||||
documentCount: number;
|
||||
/** Error message if failed */
|
||||
error: string | null;
|
||||
requestedBy: string;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BackupConfigDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
/** Containers to include in scheduled backups */
|
||||
includedContainers: string[];
|
||||
/** Containers to exclude from backups */
|
||||
excludedContainers: string[];
|
||||
/** Whether scheduled backups are enabled */
|
||||
scheduledBackupsEnabled: boolean;
|
||||
/** Schedule description (e.g. "daily", "weekly") */
|
||||
schedule: string;
|
||||
/** Backup retention in days */
|
||||
retentionDays: number;
|
||||
/** Max number of backups to retain */
|
||||
maxBackups: number;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
// ── Schemas ──────────────────────────────────────────────────────
|
||||
|
||||
export const CreateBackupSchema = z.object({
|
||||
label: z.string().min(1).max(200),
|
||||
type: z.enum(['full', 'incremental', 'selective']).default('full'),
|
||||
containers: z.array(z.string().min(1).max(128)).max(50).optional(),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
|
||||
export const RestoreBackupSchema = z.object({
|
||||
backupId: z.string().min(1),
|
||||
containers: z.array(z.string().min(1).max(128)).max(50).optional(),
|
||||
});
|
||||
|
||||
export const UpdateBackupConfigSchema = z.object({
|
||||
includedContainers: z.array(z.string().min(1).max(128)).max(100).optional(),
|
||||
excludedContainers: z.array(z.string().min(1).max(128)).max(100).optional(),
|
||||
scheduledBackupsEnabled: z.boolean().optional(),
|
||||
schedule: z.string().min(1).max(100).optional(),
|
||||
retentionDays: z.number().int().min(1).max(365).optional(),
|
||||
maxBackups: z.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
export type CreateBackupInput = z.infer<typeof CreateBackupSchema>;
|
||||
export type RestoreBackupInput = z.infer<typeof RestoreBackupSchema>;
|
||||
export type UpdateBackupConfigInput = z.infer<typeof UpdateBackupConfigSchema>;
|
||||
Loading…
Reference in New Issue
Block a user