import type { ElyD1DatabaseSession, ElyD1PreparedStatement, ElyD1Result, Env, } from "./bindings.js"; import { primaryD1Session } from "./bindings.js"; import { collectSyncR2Garbage } from "./sync_r2_gc.js"; const MARK_CLEANUP_READY_QUERY = ` UPDATE sync_vault_rotations SET cleanup_snapshot_id = ?, cleanup_started_at = ? WHERE user_id = ? AND completed_at IS NOT NULL AND storage_cleaned_at IS NULL AND cleanup_snapshot_id IS NULL AND new_generation <= ? AND EXISTS ( SELECT 1 FROM sync_vault_accounts AS account WHERE account.user_id = sync_vault_rotations.user_id AND account.current_key_id = ? AND account.current_generation = ? ) AND EXISTS ( SELECT 1 FROM sync_snapshots AS snapshot INNER JOIN sync_snapshot_encryption AS encryption ON encryption.user_id = snapshot.user_id AND encryption.snapshot_id = snapshot.snapshot_id INNER JOIN sync_snapshot_heads AS head ON head.user_id = snapshot.user_id AND head.snapshot_id = snapshot.snapshot_id AND head.head_revision = snapshot.head_revision AND head.payload_hash = snapshot.payload_hash WHERE snapshot.user_id = sync_vault_rotations.user_id AND snapshot.snapshot_id = ? AND encryption.key_id = ? AND encryption.vault_generation = ? ) `; const READY_ROTATIONS_QUERY = ` SELECT idempotency_key, new_key_id, new_generation FROM sync_vault_rotations WHERE user_id = ? AND cleanup_snapshot_id IS NOT NULL AND storage_cleaned_at IS NULL ORDER BY new_generation ASC, idempotency_key ASC `; const DELETE_CHANGE_LOG_QUERY = ` DELETE FROM sync_change_log WHERE user_id = ? AND object_id IN ( SELECT object.object_id FROM sync_objects AS object INNER JOIN sync_vault_rotation_r2_objects AS staged ON staged.user_id = object.user_id AND staged.r2_key = object.payload_r2_key WHERE object.user_id = ? AND staged.rotation_idempotency_key = ? ) `; const FENCE_ROTATION_R2_QUERY = ` UPDATE sync_r2_gc_candidates SET state = 'ready', lease_expires_at = ?, updated_at = MAX(updated_at, ?), ready_at = COALESCE(ready_at, ?) WHERE state = 'referenced' AND r2_key IN ( SELECT r2_key FROM sync_vault_rotation_r2_objects WHERE user_id = ? AND rotation_idempotency_key = ? ) `; const DELETE_TOMBSTONES_QUERY = ` DELETE FROM sync_tombstones WHERE user_id = ? AND object_id IN ( SELECT object.object_id FROM sync_objects AS object INNER JOIN sync_vault_rotation_r2_objects AS staged ON staged.user_id = object.user_id AND staged.r2_key = object.payload_r2_key WHERE object.user_id = ? AND staged.rotation_idempotency_key = ? ) `; const DELETE_OBJECTS_QUERY = ` DELETE FROM sync_objects WHERE user_id = ? AND payload_r2_key IN ( SELECT r2_key FROM sync_vault_rotation_r2_objects WHERE user_id = ? AND rotation_idempotency_key = ? ) `; const DELETE_SNAPSHOT_ENCRYPTION_QUERY = ` DELETE FROM sync_snapshot_encryption WHERE user_id = ? AND NOT (key_id = ? AND vault_generation = ?) AND NOT EXISTS ( SELECT 1 FROM sync_snapshot_heads AS head WHERE head.user_id = sync_snapshot_encryption.user_id AND head.snapshot_id = sync_snapshot_encryption.snapshot_id ) AND snapshot_id IN ( SELECT snapshot.snapshot_id FROM sync_snapshots AS snapshot INNER JOIN sync_vault_rotation_r2_objects AS staged ON staged.user_id = snapshot.user_id AND staged.r2_key = snapshot.r2_key WHERE snapshot.user_id = ? AND staged.rotation_idempotency_key = ? ) `; const DELETE_SNAPSHOTS_QUERY = ` DELETE FROM sync_snapshots WHERE user_id = ? AND NOT EXISTS ( SELECT 1 FROM sync_snapshot_heads AS head WHERE head.user_id = sync_snapshots.user_id AND head.snapshot_id = sync_snapshots.snapshot_id ) AND r2_key IN ( SELECT r2_key FROM sync_vault_rotation_r2_objects WHERE user_id = ? AND rotation_idempotency_key = ? ) AND NOT EXISTS ( SELECT 1 FROM sync_snapshot_encryption AS encryption WHERE encryption.user_id = sync_snapshots.user_id AND encryption.snapshot_id = sync_snapshots.snapshot_id ) `; const MARK_STORAGE_CLEAN_QUERY = ` UPDATE sync_vault_rotations SET storage_cleaned_at = ? WHERE user_id = ? AND idempotency_key = ? AND cleanup_snapshot_id IS NOT NULL AND storage_cleaned_at IS NULL AND NOT EXISTS ( SELECT 1 FROM sync_vault_rotation_r2_objects AS staged WHERE staged.user_id = sync_vault_rotations.user_id AND staged.rotation_idempotency_key = sync_vault_rotations.idempotency_key AND NOT EXISTS ( SELECT 1 FROM sync_objects AS object WHERE object.payload_r2_key = staged.r2_key ) AND NOT EXISTS ( SELECT 1 FROM sync_snapshots AS snapshot WHERE snapshot.r2_key = staged.r2_key ) AND NOT EXISTS ( SELECT 1 FROM sync_r2_gc_candidates AS candidate WHERE candidate.r2_key = staged.r2_key AND candidate.state = 'deleted' ) ) `; const FINALIZE_STORAGE_CLEAN_QUERY = ` UPDATE sync_vault_rotations SET storage_cleaned_at = MAX(cleanup_started_at, ?) WHERE cleanup_snapshot_id IS NOT NULL AND storage_cleaned_at IS NULL AND NOT EXISTS ( SELECT 1 FROM sync_vault_rotation_r2_objects AS staged WHERE staged.user_id = sync_vault_rotations.user_id AND staged.rotation_idempotency_key = sync_vault_rotations.idempotency_key AND NOT EXISTS ( SELECT 1 FROM sync_objects AS object WHERE object.payload_r2_key = staged.r2_key ) AND NOT EXISTS ( SELECT 1 FROM sync_snapshots AS snapshot WHERE snapshot.r2_key = staged.r2_key ) AND NOT EXISTS ( SELECT 1 FROM sync_r2_gc_candidates AS candidate WHERE candidate.r2_key = staged.r2_key AND candidate.state = 'deleted' ) ) `; interface RotationRow { idempotency_key: unknown; new_key_id: unknown; new_generation: unknown; } export class SyncVaultRotationCleanupError extends Error {} export async function cleanupRotatedVaultStorage( env: Env, userId: string, snapshotId: string, keyId: string, generation: number, nowSeconds: number, ): Promise { const database = primaryD1Session(env.ELY_DB); await database.prepare(MARK_CLEANUP_READY_QUERY).bind( snapshotId, nowSeconds, userId, generation, keyId, generation, snapshotId, keyId, generation, ).run(); const ready = await database.prepare(READY_ROTATIONS_QUERY) .bind(userId) .all(); for (const row of ready.results) { const idempotencyKey = storedIdempotencyKey(row.idempotency_key); const rotationKeyId = storedKeyId(row.new_key_id); const rotationGeneration = storedGeneration(row.new_generation); await database.batch(cleanupMetadataStatements( database, userId, idempotencyKey, rotationKeyId, rotationGeneration, nowSeconds, )); await collectSyncR2Garbage(env, nowSeconds, { userId, limit: 100, database }); const result = await database.prepare(MARK_STORAGE_CLEAN_QUERY) .bind(nowSeconds, userId, idempotencyKey) .run() as ElyD1Result; const changes = result.meta?.changes; if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes > 1 || changes < 0) { throw new SyncVaultRotationCleanupError("sync_vault_rotation_cleanup_write_invalid"); } } } export async function finalizeCleanedVaultRotations( env: Env, nowSeconds: number, ): Promise { const result = await primaryD1Session(env.ELY_DB) .prepare(FINALIZE_STORAGE_CLEAN_QUERY) .bind(nowSeconds) .run() as ElyD1Result; const changes = result.meta?.changes; if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) { throw new SyncVaultRotationCleanupError("sync_vault_rotation_cleanup_write_invalid"); } return changes; } function cleanupMetadataStatements( database: ElyD1DatabaseSession, userId: string, idempotencyKey: string, keyId: string, generation: number, nowSeconds: number, ): ElyD1PreparedStatement[] { return [ database.prepare(FENCE_ROTATION_R2_QUERY).bind( nowSeconds, nowSeconds, nowSeconds, userId, idempotencyKey, ), database.prepare(DELETE_CHANGE_LOG_QUERY).bind(userId, userId, idempotencyKey), database.prepare(DELETE_TOMBSTONES_QUERY).bind(userId, userId, idempotencyKey), database.prepare(DELETE_OBJECTS_QUERY).bind(userId, userId, idempotencyKey), database.prepare(DELETE_SNAPSHOT_ENCRYPTION_QUERY).bind( userId, keyId, generation, userId, idempotencyKey, ), database.prepare(DELETE_SNAPSHOTS_QUERY).bind(userId, userId, idempotencyKey), ]; } function storedIdempotencyKey(value: unknown): string { if (typeof value !== "string" || !/^[a-zA-Z0-9._:-]{16,128}$/.test(value)) { throw new SyncVaultRotationCleanupError("sync_vault_rotation_idempotency_key_invalid"); } return value; } function storedKeyId(value: unknown): string { if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) { throw new SyncVaultRotationCleanupError("sync_vault_rotation_key_id_invalid"); } return value; } function storedGeneration(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 2) { throw new SyncVaultRotationCleanupError("sync_vault_rotation_generation_invalid"); } return value; }