first commit
This commit is contained in:
153
packages/core/src/cleanup.ts
Normal file
153
packages/core/src/cleanup.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* System cleanup
|
||||
*
|
||||
* Runs periodic maintenance tasks that prevent unbounded accumulation of
|
||||
* expired or stale data. Called from cron scheduler ticks and (for latency-
|
||||
* sensitive subsystems) inline during relevant requests.
|
||||
*
|
||||
* Each subsystem cleanup is independent and non-fatal -- if one fails, the
|
||||
* rest still run. Failures are logged but never surface to callers.
|
||||
*/
|
||||
|
||||
import { createKyselyAdapter, type AuthTables } from "@emdashcms/auth/adapters/kysely";
|
||||
import { sql, type Kysely } from "kysely";
|
||||
|
||||
import { cleanupExpiredChallenges } from "./auth/challenge-store.js";
|
||||
import { MediaRepository } from "./database/repositories/media.js";
|
||||
import { RevisionRepository } from "./database/repositories/revision.js";
|
||||
import type { Database } from "./database/types.js";
|
||||
import type { Storage } from "./storage/types.js";
|
||||
|
||||
/**
|
||||
* Result of a system cleanup run.
|
||||
* Each field is the number of rows deleted, or -1 if the cleanup failed.
|
||||
*/
|
||||
export interface CleanupResult {
|
||||
challenges: number;
|
||||
expiredTokens: number;
|
||||
pendingUploads: number;
|
||||
pendingUploadFiles: number;
|
||||
revisionsPruned: number;
|
||||
}
|
||||
|
||||
/** Max revisions to keep per entry during periodic pruning */
|
||||
const REVISION_KEEP_COUNT = 50;
|
||||
|
||||
/** Only prune entries that exceed this threshold */
|
||||
const REVISION_PRUNE_THRESHOLD = REVISION_KEEP_COUNT;
|
||||
|
||||
/**
|
||||
* Run all system cleanup tasks.
|
||||
*
|
||||
* Safe to call frequently -- each task is a single DELETE with a WHERE clause,
|
||||
* so repeated calls with nothing to clean are cheap (no-op queries).
|
||||
*
|
||||
* @param db - The database instance
|
||||
* @param storage - Optional storage backend for deleting orphaned files.
|
||||
* When omitted, pending upload DB rows are still deleted but the
|
||||
* corresponding files in object storage are not removed.
|
||||
*/
|
||||
export async function runSystemCleanup(
|
||||
db: Kysely<Database>,
|
||||
storage?: Storage,
|
||||
): Promise<CleanupResult> {
|
||||
const result: CleanupResult = {
|
||||
challenges: -1,
|
||||
expiredTokens: -1,
|
||||
pendingUploads: -1,
|
||||
pendingUploadFiles: -1,
|
||||
revisionsPruned: -1,
|
||||
};
|
||||
|
||||
// 1. Passkey challenges (expire after 60s, clean anything past 5 min)
|
||||
try {
|
||||
result.challenges = await cleanupExpiredChallenges(db);
|
||||
} catch (error) {
|
||||
console.error("[cleanup] Failed to clean expired challenges:", error);
|
||||
}
|
||||
|
||||
// 2. Magic link / invite / signup tokens
|
||||
try {
|
||||
// Cast needed: Database extends AuthTables but uses Generated<> wrappers
|
||||
// that confuse structural checks. The adapter casts internally anyway.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Database uses Generated<> wrappers incompatible with AuthTables structurally; safe at runtime
|
||||
const authAdapter = createKyselyAdapter(db as unknown as Kysely<AuthTables>);
|
||||
await authAdapter.deleteExpiredTokens();
|
||||
result.expiredTokens = 0; // deleteExpiredTokens returns void
|
||||
} catch (error) {
|
||||
console.error("[cleanup] Failed to clean expired tokens:", error);
|
||||
}
|
||||
|
||||
// 3. Pending media uploads (abandoned after 1 hour)
|
||||
// Delete DB rows first, then remove corresponding files from storage.
|
||||
try {
|
||||
const mediaRepo = new MediaRepository(db);
|
||||
const orphanedKeys = await mediaRepo.cleanupPendingUploads();
|
||||
result.pendingUploads = orphanedKeys.length;
|
||||
|
||||
// Delete orphaned files from object storage
|
||||
if (storage && orphanedKeys.length > 0) {
|
||||
let filesDeleted = 0;
|
||||
for (const key of orphanedKeys) {
|
||||
try {
|
||||
await storage.delete(key);
|
||||
filesDeleted++;
|
||||
} catch (error) {
|
||||
// Log per-file failures but continue -- storage.delete is
|
||||
// documented as idempotent, so this is an unexpected error.
|
||||
console.error(`[cleanup] Failed to delete storage file ${key}:`, error);
|
||||
}
|
||||
}
|
||||
result.pendingUploadFiles = filesDeleted;
|
||||
} else {
|
||||
result.pendingUploadFiles = 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[cleanup] Failed to clean pending uploads:", error);
|
||||
}
|
||||
|
||||
// 4. Revision pruning -- trim entries with excessive revision counts
|
||||
try {
|
||||
result.revisionsPruned = await pruneExcessiveRevisions(db);
|
||||
} catch (error) {
|
||||
console.error("[cleanup] Failed to prune revisions:", error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find entries with more than REVISION_PRUNE_THRESHOLD revisions and prune
|
||||
* them down to REVISION_KEEP_COUNT.
|
||||
*/
|
||||
async function pruneExcessiveRevisions(db: Kysely<Database>): Promise<number> {
|
||||
const entries = await sql<{ collection: string; entry_id: string; cnt: number }>`
|
||||
SELECT collection, entry_id, COUNT(*) as cnt
|
||||
FROM revisions
|
||||
GROUP BY collection, entry_id
|
||||
HAVING cnt > ${REVISION_PRUNE_THRESHOLD}
|
||||
`.execute(db);
|
||||
|
||||
if (entries.rows.length === 0) return 0;
|
||||
|
||||
const revisionRepo = new RevisionRepository(db);
|
||||
let totalPruned = 0;
|
||||
|
||||
for (const row of entries.rows) {
|
||||
try {
|
||||
const pruned = await revisionRepo.pruneOldRevisions(
|
||||
row.collection,
|
||||
row.entry_id,
|
||||
REVISION_KEEP_COUNT,
|
||||
);
|
||||
totalPruned += pruned;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[cleanup] Failed to prune revisions for ${row.collection}/${row.entry_id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return totalPruned;
|
||||
}
|
||||
Reference in New Issue
Block a user