feat(workflows): add revision history, trash, UUID naming, and backup
- Store up to 50 revisions per workflow in SQLite with normalized SHA dedup - Soft-delete workflows to trash (7-day retention) with restore and permanent purge - Assign UUID filenames for new and duplicated workflows; show name + file in UI - Warn on invalid YAML, unknown scripts, and plaintext secrets with save-anyway option - Add workflow backup zip (workflows + plugins) and merge/replace restore - Trash page, history panel on editor, and smoke test Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { deleteRevisionHistory } from "./workflow-history.js";
|
||||
import { DATA_DIR, WORKFLOWS_DIR } from "./paths.js";
|
||||
import * as fsStore from "./fs-store.js";
|
||||
|
||||
export const TRASH_WORKFLOWS_DIR = path.join(DATA_DIR, "trash", "workflows");
|
||||
export const TRASH_RETENTION_DAYS = 7;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function trashFilePath(owner, file) {
|
||||
return path.join(TRASH_WORKFLOWS_DIR, owner, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} deletedAtIso
|
||||
*/
|
||||
export function trashAgeMs(deletedAtIso) {
|
||||
return Date.now() - Date.parse(deletedAtIso);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} deletedAtIso
|
||||
*/
|
||||
export function trashDaysRemaining(deletedAtIso) {
|
||||
const purgeAt =
|
||||
Date.parse(deletedAtIso) + TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
return Math.max(0, Math.ceil((purgeAt - Date.now()) / (24 * 60 * 60 * 1000)));
|
||||
}
|
||||
|
||||
function rowToItem(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
workflow_id: row.workflow_id,
|
||||
owner: row.owner,
|
||||
file: row.file,
|
||||
name: row.name ?? null,
|
||||
deleted_at: row.deleted_at,
|
||||
trash_path: row.trash_path,
|
||||
age_ms: trashAgeMs(row.deleted_at),
|
||||
days_until_purge: trashDaysRemaining(row.deleted_at),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listTrash() {
|
||||
const rows = await db("workflow_trash").orderBy("deleted_at", "desc");
|
||||
return rows.map(rowToItem);
|
||||
}
|
||||
|
||||
export async function getTrashItem(id) {
|
||||
const row = await db("workflow_trash").where({ id }).first();
|
||||
return row ? rowToItem(row) : null;
|
||||
}
|
||||
|
||||
export async function isInTrash(owner, file) {
|
||||
const row = await db("workflow_trash").where({ owner, file }).first();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete: move YAML to trash dir, unregister, keep revision history.
|
||||
* @param {{
|
||||
* workflowId: string,
|
||||
* owner: string,
|
||||
* file: string,
|
||||
* name?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
export async function moveWorkflowToTrash(opts) {
|
||||
const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
const err = new Error("workflow not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const trashPath = trashFilePath(opts.owner, opts.file);
|
||||
fs.mkdirSync(path.dirname(trashPath), { recursive: true });
|
||||
fs.renameSync(sourcePath, trashPath);
|
||||
|
||||
const registered = fsStore.readRegisters(opts.owner).filter((f) => f !== opts.file);
|
||||
fsStore.writeRegisters(opts.owner, registered);
|
||||
|
||||
const id = randomUUID();
|
||||
const deleted_at = nowIso();
|
||||
await db("workflow_trash").insert({
|
||||
id,
|
||||
workflow_id: opts.workflowId,
|
||||
owner: opts.owner,
|
||||
file: opts.file,
|
||||
name: opts.name ?? null,
|
||||
deleted_at,
|
||||
trash_path: trashPath,
|
||||
});
|
||||
|
||||
return rowToItem(await db("workflow_trash").where({ id }).first());
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore workflow from trash.
|
||||
* @param {string} trashId
|
||||
*/
|
||||
export async function restoreFromTrash(trashId) {
|
||||
const row = await db("workflow_trash").where({ id: trashId }).first();
|
||||
if (!row) {
|
||||
const err = new Error("trash item not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const destPath = path.join(WORKFLOWS_DIR, row.owner, row.file);
|
||||
if (fs.existsSync(destPath)) {
|
||||
const err = new Error("workflow file already exists");
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
if (!fs.existsSync(row.trash_path)) {
|
||||
const err = new Error("trash file missing on disk");
|
||||
err.statusCode = 410;
|
||||
throw err;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fs.renameSync(row.trash_path, destPath);
|
||||
|
||||
const registered = fsStore.readRegisters(row.owner);
|
||||
if (!registered.includes(row.file)) {
|
||||
registered.push(row.file);
|
||||
fsStore.writeRegisters(row.owner, registered);
|
||||
}
|
||||
|
||||
await db("workflow_trash").where({ id: trashId }).del();
|
||||
|
||||
return {
|
||||
owner: row.owner,
|
||||
file: row.file,
|
||||
workflow_id: row.workflow_id,
|
||||
content: fs.readFileSync(destPath, "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete a trash item and its revision history.
|
||||
* @param {string} trashId
|
||||
*/
|
||||
export async function purgeTrashItem(trashId) {
|
||||
const row = await db("workflow_trash").where({ id: trashId }).first();
|
||||
if (!row) {
|
||||
const err = new Error("trash item not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (fs.existsSync(row.trash_path)) {
|
||||
fs.unlinkSync(row.trash_path);
|
||||
}
|
||||
|
||||
await deleteRevisionHistory(row.workflow_id);
|
||||
await db("workflow_trash").where({ id: trashId }).del();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-purge trash older than retention window.
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function purgeExpiredTrash() {
|
||||
const cutoff = new Date(
|
||||
Date.now() - TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
const rows = await db("workflow_trash")
|
||||
.where("deleted_at", "<", cutoff)
|
||||
.select("id");
|
||||
for (const row of rows) {
|
||||
await purgeTrashItem(row.id);
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
Reference in New Issue
Block a user