Request signs out everywhere and freezes paid routes (423 + DB triggers); signing in within 7 days shows the pending gate with 撤销注销. A periodic idempotent worker purges personal content afterwards and keeps finance rows against a tombstoned identity. Read-only admin list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
65 lines
2.6 KiB
TypeScript
65 lines
2.6 KiB
TypeScript
import "server-only";
|
|
|
|
import {
|
|
ACCOUNT_DELETION_FIRST_TICK_MS,
|
|
ACCOUNT_DELETION_TICK_MS,
|
|
purgeErrorCode,
|
|
runAccountDeletionTick,
|
|
} from "@/lib/account-deletion-worker-core";
|
|
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
|
|
|
type WorkerGlobal = typeof globalThis & { jyotishaAccountDeletionWorker?: { stop: () => void } };
|
|
|
|
async function tick(): Promise<void> {
|
|
const admin = createAdminSupabaseClient();
|
|
await runAccountDeletionTick({
|
|
now: () => new Date(),
|
|
listDue: async (nowIso, limit, maxAttempts) => {
|
|
const { data, error } = await admin
|
|
.from("account_deletion_requests")
|
|
.select("id,attempt_count")
|
|
.eq("status", "pending")
|
|
.lte("scheduled_for", nowIso)
|
|
.order("scheduled_for", { ascending: true })
|
|
.limit(limit * 2);
|
|
if (error || !Array.isArray(data)) return [];
|
|
// Attempts are filtered here: the self-hosted query builder has no lt()
|
|
// (an unimplemented filter once broke a list for two weeks, BUG-990).
|
|
return (data as { id: unknown; attempt_count: unknown }[])
|
|
.filter((row) => typeof row.attempt_count !== "number" || row.attempt_count < maxAttempts)
|
|
.slice(0, limit)
|
|
.map((row) => String(row.id));
|
|
},
|
|
purge: async (requestId) => {
|
|
const { data, error } = await admin.rpc("purge_deleted_account", { p_request_id: requestId });
|
|
if (error) return { ok: false, code: purgeErrorCode(error.message) };
|
|
const status = (data as { status?: unknown } | null)?.status;
|
|
return { ok: true, status: typeof status === "string" ? status : "unknown" };
|
|
},
|
|
markFailed: async (requestId, code) => {
|
|
await admin.rpc("mark_account_deletion_attempt_failed", { p_request_id: requestId, p_error_code: code });
|
|
},
|
|
log: (line) => console.info(line),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* One unref'ed timer per server process: first run a minute after start, then
|
|
* every 30 minutes. Several instances are safe — the purge locks the request
|
|
* row and a completed request is skipped.
|
|
*/
|
|
export function startAccountDeletionWorker(): void {
|
|
const state = globalThis as WorkerGlobal;
|
|
if (state.jyotishaAccountDeletionWorker) return;
|
|
const run = () => {
|
|
tick().catch((error: unknown) => {
|
|
console.error(`[account-deletion-worker] tick failed reason=${error instanceof Error ? error.name : "UnknownError"}`);
|
|
});
|
|
};
|
|
const first = setTimeout(run, ACCOUNT_DELETION_FIRST_TICK_MS);
|
|
const every = setInterval(run, ACCOUNT_DELETION_TICK_MS);
|
|
first.unref?.();
|
|
every.unref?.();
|
|
state.jyotishaAccountDeletionWorker = { stop: () => { clearTimeout(first); clearInterval(every); } };
|
|
}
|