Ghost register entries returned 404 on delete and stayed in the list. Unregister first so move-to-trash still removes them when the file is gone. Co-authored-by: Cursor <cursoragent@cursor.com>
204 lines
5.5 KiB
JavaScript
204 lines
5.5 KiB
JavaScript
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
|
|
*/
|
|
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);
|
|
}
|
|
|
|
function unregisterWorkflow(owner, file) {
|
|
const registered = fsStore.readRegisters(owner).filter((f) => f !== file);
|
|
fsStore.writeRegisters(owner, registered);
|
|
}
|
|
|
|
/**
|
|
* Soft-delete: unregister, move YAML to trash when present, keep revision history.
|
|
* Missing YAML (ghost register entries) is still unregistered so it leaves the list.
|
|
* @param {{
|
|
* workflowId: string,
|
|
* owner: string,
|
|
* file: string,
|
|
* name?: string | null,
|
|
* }} opts
|
|
* @returns {Promise<ReturnType<typeof rowToItem> | null>} trash item, or null if there was no file to keep
|
|
*/
|
|
export async function moveWorkflowToTrash(opts) {
|
|
unregisterWorkflow(opts.owner, opts.file);
|
|
|
|
const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file);
|
|
if (!fs.existsSync(sourcePath)) {
|
|
const existing = await db("workflow_trash")
|
|
.where({ owner: opts.owner, file: opts.file })
|
|
.first();
|
|
return existing ? rowToItem(existing) : null;
|
|
}
|
|
|
|
const trashPath = trashFilePath(opts.owner, opts.file);
|
|
fs.mkdirSync(path.dirname(trashPath), { recursive: true });
|
|
fs.renameSync(sourcePath, trashPath);
|
|
|
|
const existing = await db("workflow_trash")
|
|
.where({ owner: opts.owner, file: opts.file })
|
|
.first();
|
|
if (existing) {
|
|
await db("workflow_trash").where({ id: existing.id }).update({
|
|
workflow_id: opts.workflowId,
|
|
name: opts.name ?? existing.name ?? null,
|
|
deleted_at: nowIso(),
|
|
trash_path: trashPath,
|
|
});
|
|
return rowToItem(await db("workflow_trash").where({ id: existing.id }).first());
|
|
}
|
|
|
|
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;
|
|
}
|