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

+78 -5
View File
@@ -4,6 +4,7 @@ import {
mkdir,
readdir,
readFile,
rename,
stat,
unlink,
writeFile,
@@ -27,6 +28,7 @@ export function redactErrorText(value: string): string {
export interface CmsErrorRecord {
id: string;
resolved?: boolean;
assignment?: { userId: number; at: string } | null;
at: string;
source: "server" | "browser";
event: string;
@@ -111,7 +113,9 @@ export class ErrorStore {
.slice(0, 10);
for (const name of files)
if (
/^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.resolved)$/.test(name) &&
/^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.(?:resolved|assignment)(?:\.[a-f0-9-]+\.tmp)?)$/.test(
name,
) &&
name.slice(0, 10) < cutoff
)
await unlink(path.join(this.directory, name)).catch(() => {});
@@ -135,6 +139,43 @@ export class ErrorStore {
{ mode: 0o600 },
);
}
async assign(fingerprint: string, userId: number | null) {
if (!/^[a-f0-9]{16}$/.test(fingerprint))
throw new Error("Invalid fingerprint");
if (userId !== null && (!Number.isSafeInteger(userId) || userId <= 0))
throw new Error("Invalid assignee");
if (ErrorStore.pending >= 100) throw new Error("Error storage queue full");
ErrorStore.pending++;
const write = ErrorStore.queue.then(async () => {
const { records } = await this.read();
if (!records.some((record) => record.fingerprint === fingerprint))
throw new Error("Error group no longer available");
const at = new Date().toISOString();
const file = path.join(
this.directory,
`${at.slice(0, 10)}.${fingerprint}.assignment`,
);
const metadata = (await readdir(this.directory)).filter(
(name) =>
name.startsWith(at.slice(0, 10)) && name.endsWith(".assignment"),
);
if (metadata.length >= 1000 && !metadata.includes(path.basename(file)))
throw new Error("Daily assignment storage limit reached");
const temp = `${file}.${randomUUID()}.tmp`;
try {
await writeFile(temp, JSON.stringify({ userId, at }), { mode: 0o600 });
await rename(temp, file);
} finally {
await unlink(temp).catch(() => {});
}
});
ErrorStore.queue = write.catch(() => {});
try {
await write;
} finally {
ErrorStore.pending--;
}
}
async read(): Promise<{ records: CmsErrorRecord[]; truncated: boolean }> {
const files = await readdir(this.directory).catch(
(e: NodeJS.ErrnoException) => {
@@ -142,17 +183,48 @@ export class ErrorStore {
throw e;
},
);
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
.toISOString()
.slice(0, 10);
const assignments = new Map<string, CmsErrorRecord["assignment"]>();
for (const name of files
.filter(
(f) =>
/^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.assignment$/.test(f) &&
f.slice(0, 10) >= cutoff,
)
.sort()) {
const file = path.join(this.directory, name);
if ((await stat(file)).size > 512) continue;
try {
const value = JSON.parse(await readFile(file, "utf8"));
if (
typeof value.at !== "string" ||
!Number.isFinite(Date.parse(value.at))
)
continue;
if (value.userId === null) assignments.set(name.split(".")[1], null);
else if (Number.isSafeInteger(value.userId) && value.userId > 0)
assignments.set(name.split(".")[1], {
userId: value.userId,
at: value.at,
});
} catch {
/* Ignore interrupted or malformed metadata. */
}
}
const resolved = new Map<string, string>();
for (const name of files
.filter((f) => /^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f))
.filter(
(f) =>
/^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f) &&
f.slice(0, 10) >= cutoff,
)
.sort()) {
const at = await readFile(path.join(this.directory, name), "utf8");
resolved.set(name.split(".")[1], at);
}
const records: CmsErrorRecord[] = [];
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
.toISOString()
.slice(0, 10);
for (const name of files
.filter(
(f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) >= cutoff,
@@ -169,6 +241,7 @@ export class ErrorStore {
records.push({
...r,
resolved: (resolved.get(r.fingerprint) ?? "") >= r.at,
assignment: assignments.get(r.fingerprint) ?? null,
});
} catch {
/* Ignore an interrupted final write. */