Files

272 lines
9.2 KiB
TypeScript

export const CLAIM_RATE_RETENTION_MS = 24 * 60 * 60_000;
export const CLAIM_ATTEMPT_RETENTION_MS = 30 * 24 * 60 * 60_000;
export const HANDOFF_TICKET_RETENTION_MS = 24 * 60 * 60_000;
export const RETENTION_JOB_KEY = "retention_cleanup";
export const RETENTION_CRON = "17 18 * * *";
export const RETENTION_RUNNING_STALE_MS = 30 * 60_000;
export const RETENTION_SUCCESS_OVERDUE_MS = 36 * 60 * 60_000;
const RETENTION_FUTURE_SKEW_MS = 60_000;
export type RetentionCutoffs = {
now: string;
claimRateBefore: string;
claimAttemptBefore: string;
handoffTicketBefore: string;
};
export type RetentionJobRecord = {
key: string;
state: string;
run_id: string | null;
trigger: string | null;
scheduled_at: string | null;
started_at: string | null;
completed_at: string | null;
last_success_at: string | null;
result_json: string | null;
error_code: string | null;
consecutive_failures: number;
run_count: number;
created_at: string;
updated_at: string;
};
export type RetentionJobHealth = {
state: "never_run" | "running" | "healthy" | "overdue" | "failed" | "stalled" | "invalid";
trigger: "scheduled" | "manual" | null;
startedAt: string | null;
completedAt: string | null;
lastSuccessAt: string | null;
errorCode: string | null;
consecutiveFailures: number;
runCount: number;
ageSeconds: number | null;
};
function parseTimestamp(value: string | null): number | null {
if (!value) return null;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : null;
}
export function deriveRetentionJobHealth(
record: RetentionJobRecord | null,
now = new Date().toISOString(),
): RetentionJobHealth {
if (!record) {
return {
state: "never_run",
trigger: null,
startedAt: null,
completedAt: null,
lastSuccessAt: null,
errorCode: null,
consecutiveFailures: 0,
runCount: 0,
ageSeconds: null,
};
}
const nowMilliseconds = new Date(now).getTime();
const scheduledMilliseconds = parseTimestamp(record.scheduled_at);
const startedMilliseconds = parseTimestamp(record.started_at);
const completedMilliseconds = parseTimestamp(record.completed_at);
const successMilliseconds = parseTimestamp(record.last_success_at);
const countsValid = Number.isSafeInteger(record.run_count) && record.run_count >= 1
&& Number.isSafeInteger(record.consecutive_failures) && record.consecutive_failures >= 0;
const errorCodeValid = record.error_code === null
|| /^[a-z0-9_]{3,64}$/.test(record.error_code);
const trigger: RetentionJobHealth["trigger"] = record.trigger === "scheduled" || record.trigger === "manual"
? record.trigger
: null;
const base = {
trigger,
startedAt: record.started_at,
completedAt: record.completed_at,
lastSuccessAt: record.last_success_at,
errorCode: record.error_code,
consecutiveFailures: countsValid ? record.consecutive_failures : 0,
runCount: countsValid ? record.run_count : 0,
};
const invalid = !Number.isFinite(nowMilliseconds)
|| record.key !== RETENTION_JOB_KEY
|| !countsValid
|| !errorCodeValid
|| !trigger
|| !record.run_id
|| scheduledMilliseconds === null
|| !startedMilliseconds
|| scheduledMilliseconds > nowMilliseconds + RETENTION_FUTURE_SKEW_MS
|| startedMilliseconds > nowMilliseconds + RETENTION_FUTURE_SKEW_MS
|| (record.completed_at !== null && completedMilliseconds === null)
|| (record.last_success_at !== null && successMilliseconds === null)
|| (completedMilliseconds !== null && completedMilliseconds > nowMilliseconds + RETENTION_FUTURE_SKEW_MS)
|| (successMilliseconds !== null && successMilliseconds > nowMilliseconds + RETENTION_FUTURE_SKEW_MS)
|| (completedMilliseconds !== null && completedMilliseconds < startedMilliseconds)
|| (successMilliseconds !== null && completedMilliseconds !== null && successMilliseconds > completedMilliseconds);
if (invalid) {
return { state: "invalid", ...base, ageSeconds: null };
}
if (record.state === "running") {
if (record.error_code !== null) {
return { state: "invalid", ...base, ageSeconds: null };
}
const ageMilliseconds = Math.max(0, nowMilliseconds - startedMilliseconds);
return {
state: ageMilliseconds > RETENTION_RUNNING_STALE_MS ? "stalled" : "running",
...base,
ageSeconds: Math.floor(ageMilliseconds / 1_000),
};
}
if (record.state === "failed") {
if (record.error_code === null) {
return { state: "invalid", ...base, ageSeconds: null };
}
const failureAgeMilliseconds = Math.max(
0,
nowMilliseconds - (completedMilliseconds ?? startedMilliseconds),
);
return {
state: "failed",
...base,
ageSeconds: Math.floor(failureAgeMilliseconds / 1_000),
};
}
if (record.state !== "succeeded"
|| record.error_code !== null
|| successMilliseconds === null
|| completedMilliseconds === null) {
return { state: "invalid", ...base, ageSeconds: null };
}
const ageMilliseconds = Math.max(0, nowMilliseconds - successMilliseconds);
return {
state: ageMilliseconds > RETENTION_SUCCESS_OVERDUE_MS ? "overdue" : "healthy",
...base,
ageSeconds: Math.floor(ageMilliseconds / 1_000),
};
}
export function getRetentionCutoffs(now: string): RetentionCutoffs {
const nowMilliseconds = new Date(now).getTime();
if (!Number.isFinite(nowMilliseconds)) {
throw new TypeError("retention maintenance requires an ISO timestamp");
}
return {
now: new Date(nowMilliseconds).toISOString(),
claimRateBefore: new Date(nowMilliseconds - CLAIM_RATE_RETENTION_MS).toISOString(),
claimAttemptBefore: new Date(nowMilliseconds - CLAIM_ATTEMPT_RETENTION_MS).toISOString(),
handoffTicketBefore: new Date(nowMilliseconds - HANDOFF_TICKET_RETENTION_MS).toISOString(),
};
}
/**
* Pairing history remains available, but its credential-derived hash does not.
* The deterministic tombstone is unique because pairing request IDs are unique.
*/
export const RETIRE_EXPIRED_PAIRING_CODES_SQL = `
UPDATE pairing_requests
SET code_hash = 'expired:' || id
WHERE expires_at <= ?
AND code_hash != ('expired:' || id)`;
export const DELETE_EXPIRED_CLAIM_RATE_WINDOWS_SQL = `
DELETE FROM pairing_claim_rate_limits
WHERE window_start < ?`;
export const DELETE_EXPIRED_CLAIM_ATTEMPTS_SQL = `
DELETE FROM pairing_claim_attempts
WHERE created_at < ?`;
export const DELETE_EXPIRED_IDEMPOTENCY_SQL = `
DELETE FROM idempotency_records
WHERE expires_at <= ?`;
/**
* Handoff tickets contain only a one-way ticket digest and public phone identity,
* never the raw ticket or phone token. Retain consumed/expired rows briefly for
* replay diagnostics, then remove them on a bounded schedule.
*/
export const DELETE_RETIRED_PHONE_HANDOFF_TICKETS_SQL = `
DELETE FROM phone_handoff_tickets
WHERE expires_at <= ?
OR (consumed_at IS NOT NULL AND consumed_at <= ?)`;
export const DELETE_RETIRED_PENDING_PHONE_ROUTES_SQL = `
DELETE FROM phone_route_handles
WHERE status = 'pending'
AND EXISTS (
SELECT 1 FROM phone_handoff_tickets AS tickets
WHERE tickets.tenant_id = phone_route_handles.tenant_id
AND tickets.completed_phone_id = phone_route_handles.phone_id
AND tickets.completed_route_handle_hash = phone_route_handles.handle_hash
AND (
tickets.expires_at <= ?
OR (tickets.consumed_at IS NOT NULL AND tickets.consumed_at <= ?)
)
)`;
export const DELETE_RETIRED_PENDING_PHONE_PRINCIPALS_SQL = `
DELETE FROM relay_phone_principals
WHERE status = 'pending'
AND EXISTS (
SELECT 1 FROM phone_handoff_tickets AS tickets
WHERE tickets.tenant_id = relay_phone_principals.tenant_id
AND tickets.completed_phone_id = relay_phone_principals.phone_id
AND tickets.completed_phone_token_hash = relay_phone_principals.token_hash
AND (
tickets.expires_at <= ?
OR (tickets.consumed_at IS NOT NULL AND tickets.consumed_at <= ?)
)
)`;
export const DELETE_EXPIRED_DEVICE_REGISTRATION_REPLAYS_SQL = `
DELETE FROM device_registration_replays
WHERE expires_at <= ?`;
export const ACQUIRE_RETENTION_JOB_SQL = `
INSERT INTO maintenance_jobs
(key, state, run_id, trigger, scheduled_at, started_at, completed_at,
last_success_at, result_json, error_code, consecutive_failures, run_count,
created_at, updated_at)
VALUES (?, 'running', ?, ?, ?, ?, NULL, NULL, NULL, NULL, 0, 1, ?, ?)
ON CONFLICT(key) DO UPDATE SET
state = 'running',
run_id = excluded.run_id,
trigger = excluded.trigger,
scheduled_at = excluded.scheduled_at,
started_at = excluded.started_at,
completed_at = NULL,
result_json = NULL,
error_code = NULL,
run_count = maintenance_jobs.run_count + 1,
updated_at = excluded.updated_at
WHERE maintenance_jobs.state != 'running'
OR maintenance_jobs.started_at < ?`;
export const COMPLETE_RETENTION_JOB_SQL = `
UPDATE maintenance_jobs
SET state = 'succeeded',
completed_at = ?,
last_success_at = ?,
result_json = ?,
error_code = NULL,
consecutive_failures = 0,
updated_at = ?
WHERE key = ?
AND run_id = ?
AND state = 'running'`;
export const FAIL_RETENTION_JOB_SQL = `
UPDATE maintenance_jobs
SET state = 'failed',
completed_at = ?,
result_json = NULL,
error_code = ?,
consecutive_failures = consecutive_failures + 1,
updated_at = ?
WHERE key = ?
AND run_id = ?
AND state = 'running'`;