feat(cms): improve catalog, editorial recovery and operations
CI / check (push) Successful in 52s
CI / deploy (push) Successful in 2m10s
CI / publish-container (push) Failing after 1m18s

This commit is contained in:
Simo committed 2026-09-09 19:36:15 +02:00
1 parent 2fead134e6
commit c389c3893d
122 files changed
+8137 -703

No files matched your search

+55 -7
View File
@@ -39,20 +39,68 @@ export class ImportJobStore {
}
async read(id: string): Promise<ImportJob> {
if (!validJobId(id)) throw Error("Invalid import ID");
return JSON.parse(
const job: ImportJob = JSON.parse(
await fs.readFile(path.join(this.root, `${id}.json`), "utf8"),
);
if (await this.isCancellationRequested(id)) job.cancelRequested = true;
return job;
}
async list(): Promise<ImportJob[]> {
async isCancellationRequested(id: string): Promise<boolean> {
if (!validJobId(id)) throw Error("Invalid import ID");
try {
await fs.access(path.join(this.root, `${id}.cancel`));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
/** Cancellation has its own durable marker: a worker save cannot overwrite it. */
async requestCancellation(
id: string,
userId: number,
): Promise<ImportJob | null> {
const job = await this.read(id).catch((error) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (!job || job.userId !== userId) return null;
if (job.state !== "queued" && job.state !== "running") return job;
await fs
.writeFile(path.join(this.root, `${id}.cancel`), "cancel", { flag: "wx" })
.catch((error) => {
if (error.code !== "EEXIST") throw error;
});
return this.read(id);
}
async list(options?: {
userId: number;
limit: number;
}): Promise<ImportJob[]> {
const files = await fs.readdir(this.root).catch((error) => {
if (error.code === "ENOENT") return [];
throw error;
});
const jobs = await Promise.all(
files
.filter((f) => f.endsWith(".json") && validJobId(f.slice(0, -5)))
.map((f) => this.read(f.slice(0, -5))),
const candidates: Array<{ id: string; modified: number }> = [];
// Metadata and payload reads are sequential, avoiding thousands of concurrent opens.
for (const file of files) {
if (!file.endsWith(".json") || !validJobId(file.slice(0, -5))) continue;
const stat = await fs.stat(path.join(this.root, file));
candidates.push({ id: file.slice(0, -5), modified: stat.mtimeMs });
}
candidates.sort((a, b) =>
options ? b.modified - a.modified : a.modified - b.modified,
);
return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
const jobs: ImportJob[] = [];
const limit = options ? Math.min(30, Math.max(1, options.limit)) : Infinity;
for (const candidate of candidates) {
const job = await this.read(candidate.id);
if (options && job.userId !== options.userId) continue;
jobs.push(job);
if (jobs.length >= limit) break;
}
return options
? jobs
: jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
}