283 lines
9.4 KiB
TypeScript
283 lines
9.4 KiB
TypeScript
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<void> {
|
|
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<RotationRow>();
|
|
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<number> {
|
|
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;
|
|
}
|