From 5683524f5008062a0d3d9ecb3bc37a16f3d44406 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 22 Jul 2026 17:24:10 +0300 Subject: [PATCH 1/8] fix(manifest): trash R2 before deleting rows and harden cleanup queue Soft-deleted versions were leaving millions of manifest rows because cleanup deleted DB tracking first and silently ACKed failures. Move objects to the 7-day lifecycle trash only after an existence check, delete each row after that succeeds, raise version-queue timeout/retries, add a sweeper, and reclaim stuck deleted manifests with the same path. Co-authored-by: Cursor --- scripts/reclaim_deleted_version_manifests.ts | 222 ++++++++++++++++ .../_backend/triggers/on_version_update.ts | 251 ++++++++++-------- .../_backend/triggers/queue_consumer.ts | 19 +- supabase/functions/_backend/utils/s3.ts | 11 +- ...095157_sweep_deleted_version_manifests.sql | 133 ++++++++++ tests/on-version-update-cleanup.unit.test.ts | 244 ++++++++++++++--- .../queue-consumer-message-shape.unit.test.ts | 4 +- 7 files changed, 739 insertions(+), 145 deletions(-) create mode 100644 scripts/reclaim_deleted_version_manifests.ts create mode 100644 supabase/migrations/20260722095157_sweep_deleted_version_manifests.sql diff --git a/scripts/reclaim_deleted_version_manifests.ts b/scripts/reclaim_deleted_version_manifests.ts new file mode 100644 index 0000000000..374a959567 --- /dev/null +++ b/scripts/reclaim_deleted_version_manifests.ts @@ -0,0 +1,222 @@ +/** + * Reclaim ALL soft-deleted app_versions that still have public.manifest rows. + * + * For each file: + * 1) if R2 object exists → move to deleted-after-7-days/ + * 2) if missing → continue + * 3) only then delete the DB row + * + * Shared delta files referenced by other versions stay in R2. + * + * Usage: + * bun scripts/reclaim_deleted_version_manifests.ts + */ +import { CopyObjectCommand, DeleteObjectCommand, HeadObjectCommand, S3Client } from '@aws-sdk/client-s3' +import pg from 'pg' + +const ENV_FILE = './internal/cloudflare/.env.prod' +const TRASH_PREFIX = 'deleted-after-7-days/' +const CONCURRENCY = 50 +const VERSION_PAGE = 100 +const DB_URL_ENV_KEYS = [ + 'MAIN_SUPABASE_DB_URL', + 'DATABASE_URL', + 'POSTGRES_URL', + 'SUPABASE_DB_URL', + 'SUPABASE_DB_DIRECT_URL', + 'DIRECT_URL', +] + +function loadEnv(filePath: string) { + return Bun.file(filePath).text().then((text) => { + const env: Record = {} + for (const line of text.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) + continue + const idx = trimmed.indexOf('=') + if (idx <= 0) + continue + env[trimmed.slice(0, idx)] = trimmed.slice(idx + 1) + } + return env + }) +} + +function requireDbUrl(env: Record) { + for (const key of DB_URL_ENV_KEYS) { + if (env[key]) + return env[key] + } + throw new Error(`Missing DB URL. Set one of: ${DB_URL_ENV_KEYS.join(', ')}`) +} + +async function mapPool(items: T[], concurrency: number, fn: (item: T) => Promise) { + let idx = 0 + const workers = Array.from({ length: Math.min(concurrency, Math.max(items.length, 1)) }, async () => { + while (idx < items.length) { + const current = items[idx++] + await fn(current) + } + }) + await Promise.all(workers) +} + +async function objectExists(s3: S3Client, bucket: string, key: string) { + try { + await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })) + return true + } + catch (error: any) { + const status = error?.$metadata?.httpStatusCode ?? error?.statusCode ?? error?.status + const code = error?.name ?? error?.Code ?? error?.code + if (status === 404 || code === 'NotFound' || code === 'NoSuchKey') + return false + throw error + } +} + +async function moveToTrash(s3: S3Client, bucket: string, key: string) { + if (key.startsWith(TRASH_PREFIX)) + return + + const exists = await objectExists(s3, bucket, key) + if (!exists) + return + + await s3.send(new CopyObjectCommand({ + Bucket: bucket, + CopySource: `${bucket}/${key}`, + Key: `${TRASH_PREFIX}${key}`, + })) + await s3.send(new DeleteObjectCommand({ + Bucket: bucket, + Key: key, + })) +} + +async function main() { + const env = await loadEnv(ENV_FILE) + const db = new pg.Client({ connectionString: requireDbUrl(env), ssl: { rejectUnauthorized: false } }) + await db.connect() + + const s3 = new S3Client({ + credentials: { + accessKeyId: env.S3_ACCESS_KEY_ID, + secretAccessKey: env.S3_SECRET_ACCESS_KEY, + }, + endpoint: `https://${env.S3_ENDPOINT}`, + region: env.S3_REGION || 'auto', + forcePathStyle: true, + }) + const bucket = env.S3_BUCKET || 'capgo' + + console.log('Listing soft-deleted versions with leftover manifests...') + const versionsRes = await db.query<{ id: number, app_id: string, manifest_count: number }>(` + SELECT av.id, av.app_id, av.manifest_count + FROM public.app_versions AS av + WHERE av.deleted = true + AND ( + av.manifest_count > 0 + OR EXISTS ( + SELECT 1 FROM public.manifest AS m WHERE m.app_version_id = av.id + ) + ) + AND av.app_id NOT LIKE 'com.capdemo%' + ORDER BY av.id + `) + const versions = versionsRes.rows + console.log(`Found ${versions.length} versions`) + + let done = 0 + let totalTrashed = 0 + let totalDeleted = 0 + + for (let i = 0; i < versions.length; i += VERSION_PAGE) { + const page = versions.slice(i, i + VERSION_PAGE) + for (const version of page) { + const entriesRes = await db.query<{ + id: number + file_hash: string + file_name: string + s3_path: string | null + }>( + `SELECT id, file_hash, file_name, s3_path + FROM public.manifest + WHERE app_version_id = $1 + ORDER BY id`, + [version.id], + ) + const entries = entriesRes.rows + let trashed = 0 + let deletedRows = 0 + + await mapPool(entries, CONCURRENCY, async (entry) => { + if (entry.s3_path) { + const ref = await db.query( + `SELECT 1 AS ok + FROM public.manifest + WHERE file_hash = $1 + AND file_name = $2 + AND app_version_id <> $3 + LIMIT 1`, + [entry.file_hash, entry.file_name, version.id], + ) + + if (ref.rows.length === 0) { + await moveToTrash(s3, bucket, entry.s3_path) + trashed += 1 + } + } + + await db.query(`DELETE FROM public.manifest WHERE id = $1`, [entry.id]) + deletedRows += 1 + }) + + const remaining = await db.query( + `SELECT COUNT(*)::int AS count FROM public.manifest WHERE app_version_id = $1`, + [version.id], + ) + if (Number(remaining.rows[0]?.count ?? 0) > 0) + throw new Error(`version ${version.id} still has manifest rows`) + + await db.query('BEGIN') + try { + await db.query( + `UPDATE public.app_versions + SET manifest_count = 0, manifest = NULL + WHERE id = $1`, + [version.id], + ) + if (deletedRows > 0 || (version.manifest_count ?? 0) > 0) { + await db.query( + `UPDATE public.apps + SET manifest_bundle_count = GREATEST(manifest_bundle_count - 1, 0), + updated_at = now() + WHERE app_id = $1`, + [version.app_id], + ) + } + await db.query('COMMIT') + } + catch (error) { + await db.query('ROLLBACK') + throw error + } + + done += 1 + totalTrashed += trashed + totalDeleted += deletedRows + process.stdout.write(`\rCleaned ${done}/${versions.length} versions (rows=${totalDeleted}, r2=${totalTrashed})`) + } + } + + await db.end() + process.stdout.write('\n') + console.log('Done.') + console.log(`Versions cleaned: ${done}`) + console.log(`Manifest rows deleted: ${totalDeleted}`) + console.log(`R2 objects moved to ${TRASH_PREFIX}: ${totalTrashed}`) +} + +await main() diff --git a/supabase/functions/_backend/triggers/on_version_update.ts b/supabase/functions/_backend/triggers/on_version_update.ts index 93214db3f8..a0fcebf7c0 100644 --- a/supabase/functions/_backend/triggers/on_version_update.ts +++ b/supabase/functions/_backend/triggers/on_version_update.ts @@ -316,111 +316,177 @@ async function updateIt(c: Context, record: Database['public']['Tables']['app_ve return c.json(BRES) } +const MANIFEST_TRASH_CONCURRENCY = 50 + +type ManifestCleanupEntry = { + id: number + file_hash: string + file_name: string + s3_path: string | null +} + /** - * Deletes manifest rows and moves orphaned S3 assets to the R2 trash prefix. + * Trash unreferenced R2 objects first (exist → move to deleted-after-7-days/, + * missing → ok), then delete that DB row. Never drop DB tracking before R2 is handled. + * Incomplete work throws so the queue retries; already-trashed paths are idempotent. */ async function deleteManifest(c: Context, record: Database['public']['Tables']['app_versions']['Row']) { - // Delete manifest entries - first get them to delete from S3 - const pgClient = getPgClient(c, true) // READ-ONLY: deletes use SDK, not Drizzle - const drizzleClient = getDrizzleClient(pgClient) + const readPgClient = getPgClient(c, true) + const drizzleClient = getDrizzleClient(readPgClient) + let manifestEntries: ManifestCleanupEntry[] = [] try { - const manifestEntries = await drizzleClient - .select() + manifestEntries = await drizzleClient + .select({ + id: manifest.id, + file_hash: manifest.file_hash, + file_name: manifest.file_name, + s3_path: manifest.s3_path, + }) .from(manifest) .where(eq(manifest.app_version_id, record.id)) + } + finally { + await closeClient(c, readPgClient) + } - if (manifestEntries && manifestEntries.length > 0) { - const manifestCount = manifestEntries.length + const startedWithRows = manifestEntries.length > 0 - // Move each unreferenced file to the R2 trash prefix. - const promisesMoveToTrash = [] - for (const entry of manifestEntries) { + if (startedWithRows) { + for (let i = 0; i < manifestEntries.length; i += MANIFEST_TRASH_CONCURRENCY) { + const batch = manifestEntries.slice(i, i + MANIFEST_TRASH_CONCURRENCY) + await Promise.all(batch.map(async (entry) => { if (entry.s3_path) { - promisesMoveToTrash.push( - // First delete the manifest row from database - supabaseAdmin(c) - .from('manifest') - .delete() - .eq('id', entry.id) - .then(({ error: deleteError }) => { - if (deleteError) { - cloudlog({ requestId: c.get('requestId'), message: 'error deleting manifest row', id: entry.id, error: deleteError }) - return null // Signal to skip S3 cleanup - } - // After deleting, check if any other rows still reference this file - // This avoids race condition where concurrent deletes both skip S3 cleanup - return supabaseAdmin(c) - .from('manifest') - .select('id') - .eq('file_hash', entry.file_hash) - .eq('file_name', entry.file_name) - .limit(1) - .maybeSingle() + const { data: stillReferenced, error: refError } = await supabaseAdmin(c) + .from('manifest') + .select('id') + .eq('file_hash', entry.file_hash) + .eq('file_name', entry.file_name) + .neq('app_version_id', record.id) + .limit(1) + .maybeSingle() + + if (refError) { + throw simpleError('cannot_check_manifest_references', 'Cannot check manifest file references before trash', { + id: entry.id, + s3_path: entry.s3_path, + }, refError) + } + + if (!stillReferenced) { + const moved = await s3.moveObjectToTrash(c, entry.s3_path) + if (!moved) { + throw simpleError('cannot_move_manifest_s3_to_trash', 'Cannot move S3 object for deleted manifest file to trash', { + id: entry.id, + s3_path: entry.s3_path, }) - .then((v) => { - if (!v) - return // Delete failed, skip S3 cleanup - if (v.error) { - cloudlog({ requestId: c.get('requestId'), message: 'error checking manifest references', error: v.error }) - return // Don't delete S3 if we can't confirm no other references - } - if (v.data) { - // Other versions still use this file, S3 cleanup not needed - return - } - // No other versions use this file, move it to the R2 trash prefix. - cloudlog({ requestId: c.get('requestId'), message: 'moving manifest file to R2 trash', s3_path: entry.s3_path }) - return s3.moveObjectToTrash(c, entry.s3_path) - .then((moved) => { - if (!moved) { - throw simpleError('cannot_move_manifest_s3_to_trash', 'Cannot move S3 object for deleted manifest file to trash', { id: entry.id, s3_path: entry.s3_path }) - } - }) - }), - ) + } + } } - } - await Promise.all(promisesMoveToTrash) - - // After deleting manifest entries, update manifest_count and decrement manifest_bundle_count - const updatePgClient = getPgClient(c, false) - try { - await updatePgClient.query( - `UPDATE app_versions SET manifest_count = 0, manifest = NULL WHERE id = $1`, - [record.id], - ) - - // Only decrement if this version had manifests - if (manifestCount > 0) { - await updatePgClient.query( - `UPDATE apps - SET manifest_bundle_count = GREATEST(manifest_bundle_count - 1, 0), - updated_at = now() - WHERE app_id = $1`, - [record.app_id], - ) + + // Only delete the DB row after R2 is handled (or shared and kept). + const { error: deleteError } = await supabaseAdmin(c) + .from('manifest') + .delete() + .eq('id', entry.id) + + if (deleteError) { + throw simpleError('cannot_delete_manifest_row', 'Cannot delete manifest row after R2 trash', { + id: entry.id, + s3_path: entry.s3_path, + }, deleteError) } + })) + } + } + + const writePgClient = getPgClient(c, false) + try { + await writePgClient.query('BEGIN') + try { + const remaining = await writePgClient.query( + `SELECT COUNT(*)::int AS count FROM public.manifest WHERE app_version_id = $1`, + [record.id], + ) + const remainingCount = Number(remaining.rows[0]?.count ?? 0) + if (remainingCount > 0) { + throw simpleError('manifest_cleanup_incomplete', 'Manifest rows still present after trash/delete pass', { + id: record.id, + remainingCount, + }) } - catch (error) { - cloudlog({ requestId: c.get('requestId'), message: 'error update counters on delete', error }) - } - finally { - await closeClient(c, updatePgClient) - } + + await writePgClient.query( + `WITH prev AS ( + SELECT id, app_id, manifest_count, (manifest IS NOT NULL) AS has_json + FROM public.app_versions + WHERE id = $1 + FOR UPDATE + ), + upd AS ( + UPDATE public.app_versions AS av + SET manifest_count = 0, + manifest = NULL + FROM prev + WHERE av.id = prev.id + AND (prev.manifest_count > 0 OR prev.has_json OR $2::boolean) + RETURNING prev.app_id, prev.manifest_count AS prev_count + ) + UPDATE public.apps AS a + SET manifest_bundle_count = GREATEST(a.manifest_bundle_count - 1, 0), + updated_at = now() + FROM upd + WHERE a.app_id = upd.app_id + AND (upd.prev_count > 0 OR $2::boolean)`, + [record.id, startedWithRows], + ) + + await writePgClient.query('COMMIT') + } + catch (error) { + await writePgClient.query('ROLLBACK') + throw error } } catch (error) { - cloudlog({ requestId: c.get('requestId'), message: 'error deleting manifest entries', error }) + cloudlog({ requestId: c.get('requestId'), message: 'error finalizing manifest cleanup', error, id: record.id }) + throw error } finally { - await closeClient(c, pgClient) + await closeClient(c, writePgClient) } } export async function deleteIt(c: Context, record: Database['public']['Tables']['app_versions']['Row']) { cloudlog({ requestId: c.get('requestId'), message: 'Delete', r2_path: record.r2_path }) + // Manifest files: trash R2 first, then drop DB rows. Must finish before ACK. + await deleteManifest(c, record) + + const { data, error: dbError } = await supabaseAdmin(c) + .from('app_versions_meta') + .select() + .eq('id', record.id) + .single() + if (dbError || !data) { + cloudlog({ requestId: c.get('requestId'), message: 'Cannot find version meta', id: record.id }) + } + else { + const { error: errorCreateStatsMeta } = await createStatsMeta(c, record.app_id, record.id, -data.size) + if (errorCreateStatsMeta) + cloudlog({ requestId: c.get('requestId'), message: 'error createStatsMeta', error: errorCreateStatsMeta }) + + const { error: errorUpdate } = await supabaseAdmin(c) + .from('app_versions_meta') + .update({ size: 0 }) + .eq('id', record.id) + if (errorUpdate) { + cloudlog({ requestId: c.get('requestId'), message: 'error', error: errorUpdate }) + throw simpleError('cannot_update_version_meta', 'Cannot update version metadata for deleted version', { id: record.id }, errorUpdate) + } + } + + // Bundle zip: move to lifecycle trash. Retry via queue if this fails; manifests already cleared. if (record.r2_path) { let moved = false try { @@ -439,30 +505,6 @@ export async function deleteIt(c: Context, record: Database['public']['Tables'][ cloudlog({ requestId: c.get('requestId'), message: 'No r2 path for deleted version', id: record.id }) } - const { data, error: dbError } = await supabaseAdmin(c) - .from('app_versions_meta') - .select() - .eq('id', record.id) - .single() - if (dbError || !data) { - cloudlog({ requestId: c.get('requestId'), message: 'Cannot find version meta', id: record.id }) - return c.json(BRES) - } - const { error: errorCreateStatsMeta } = await createStatsMeta(c, record.app_id, record.id, -data.size) - if (errorCreateStatsMeta) - cloudlog({ requestId: c.get('requestId'), message: 'error createStatsMeta', error: errorCreateStatsMeta }) - // set app_versions_meta versionSize = 0 - const { error: errorUpdate } = await supabaseAdmin(c) - .from('app_versions_meta') - .update({ size: 0 }) - .eq('id', record.id) - if (errorUpdate) { - cloudlog({ requestId: c.get('requestId'), message: 'error', error: errorUpdate }) - throw simpleError('cannot_update_version_meta', 'Cannot update version metadata for deleted version', { id: record.id }, errorUpdate) - } - - await deleteManifest(c, record) - return c.json(BRES) } @@ -505,4 +547,5 @@ app.post('/', middlewareAPISecret, triggerValidator('app_versions', 'UPDATE'), a export const onVersionUpdateTestUtils = { getDeletedVersionAction, + deleteManifest, } diff --git a/supabase/functions/_backend/triggers/queue_consumer.ts b/supabase/functions/_backend/triggers/queue_consumer.ts index e73b025bda..b6f2640c49 100644 --- a/supabase/functions/_backend/triggers/queue_consumer.ts +++ b/supabase/functions/_backend/triggers/queue_consumer.ts @@ -23,9 +23,10 @@ const DEFAULT_QUEUE_VISIBILITY_TIMEOUT_SECONDS = 120 const VERSION_QUEUE_VISIBILITY_TIMEOUT_SECONDS = 900 const MANIFEST_QUEUE_VISIBILITY_TIMEOUT_SECONDS = 900 const QUEUE_HTTP_TIMEOUT_MS = 15_000 -const VERSION_QUEUE_HTTP_TIMEOUT_MS = 60_000 +const VERSION_QUEUE_HTTP_TIMEOUT_MS = 300_000 // large deleted manifests: trash then DB delete const HEALTHCHECK_HTTP_TIMEOUT_MS = 8_000 export const MAX_QUEUE_READS = 5 +const VERSION_QUEUE_MAX_READS = 30 // deleted manifests can need many partial trash/delete passes const DISCORD_IGNORED_ERROR_CODES = new Set(['version_not_found', 'no_channel']) const integerLikeSchema = type('number.integer').or(type('string.numeric.parse |> number.integer')) @@ -281,6 +282,12 @@ function getQueueHttpTimeoutMs(functionName: string): number { return QUEUE_HTTP_TIMEOUT_MS } +function getQueueMaxReads(queueName: string): number { + if (isVersionQueueFunction(queueName)) + return VERSION_QUEUE_MAX_READS + return MAX_QUEUE_READS +} + function resolveFunctionUrl(c: Context, function_name: string, function_type: string | null | undefined): string { const cfPpUrl = getEnv(c, 'CLOUDFLARE_PP_FUNCTION_URL') const cfUrl = getEnv(c, 'CLOUDFLARE_FUNCTION_URL') @@ -799,8 +806,9 @@ async function processQueue(c: Context, db: ReturnType, queu } } + const retryBudget = getQueueMaxReads(queueName) const [messagesToProcess, messagesToSkip] = messages.reduce((acc, message) => { - acc[message.read_ct <= MAX_QUEUE_READS ? 0 : 1].push(message) + acc[message.read_ct <= retryBudget ? 0 : 1].push(message) return acc }, [[], []] as [typeof messages, typeof messages]) @@ -812,7 +820,7 @@ async function processQueue(c: Context, db: ReturnType, queu processingCount: messagesToProcess.length, skippedCount: messagesToSkip.length, concurrency: processConcurrency, - retryBudget: MAX_QUEUE_READS, + retryBudget, }) // Archive messages after the configured retry budget is exhausted. @@ -822,7 +830,7 @@ async function processQueue(c: Context, db: ReturnType, queu message: `[${queueName}] Archiving messages that exceeded the retry budget.`, queueName, archiveCount: messagesToSkip.length, - retryBudget: MAX_QUEUE_READS, + retryBudget, }) await archive_queue_messages(c, db, queueName, messagesToSkip.map(msg => msg.msg_id)) } @@ -978,7 +986,7 @@ export async function http_post_helper( headers['x-capgo-queue-name'] = metadata.queueName headers['x-capgo-queue-msg-id'] = String(metadata.msgId) headers['x-capgo-queue-read-count'] = String(metadata.readCount) - headers['x-capgo-queue-max-reads'] = String(MAX_QUEUE_READS) + headers['x-capgo-queue-max-reads'] = String(metadata ? getQueueMaxReads(metadata.queueName) : MAX_QUEUE_READS) } if (waitForCompletion) headers[WAIT_FOR_COMPLETION_HEADER] = 'true' @@ -1243,6 +1251,7 @@ export const __queueConsumerTestUtils__ = { getQueueAckChunkSize, getQueueHttpConcurrency, getQueueHttpTimeoutMs, + getQueueMaxReads, httpExceptionToQueueResponse, getQueueVisibilityTimeout, normalizeQueueFunctionType, diff --git a/supabase/functions/_backend/utils/s3.ts b/supabase/functions/_backend/utils/s3.ts index 42dda690b8..d94ccf3fef 100644 --- a/supabase/functions/_backend/utils/s3.ts +++ b/supabase/functions/_backend/utils/s3.ts @@ -145,6 +145,15 @@ async function moveObjectToTrash(c: Context, fileId: string) { if (fileId.startsWith(R2_TRASH_PREFIX)) return true + // Never delete tracking before we know the object state. + // Exists → copy into lifecycle trash then remove original. + // Missing → treat as already gone so callers can drop DB rows safely. + const exists = await checkIfExist(c, fileId) + if (!exists) { + cloudlog({ requestId: c.get('requestId'), message: 'R2 object missing before trash move, skip copy', fileId }) + return true + } + const client = initS3(c) const trashPath = getTrashPath(fileId) try { @@ -155,7 +164,7 @@ async function moveObjectToTrash(c: Context, fileId: string) { } catch (error) { if (isMissingObjectError(error)) { - cloudlog({ requestId: c.get('requestId'), message: 'R2 object already missing before trash move', fileId, error: serializeStorageError(error) }) + cloudlog({ requestId: c.get('requestId'), message: 'R2 object disappeared during trash move', fileId, error: serializeStorageError(error) }) return true } diff --git a/supabase/migrations/20260722095157_sweep_deleted_version_manifests.sql b/supabase/migrations/20260722095157_sweep_deleted_version_manifests.sql new file mode 100644 index 0000000000..eebc34e068 --- /dev/null +++ b/supabase/migrations/20260722095157_sweep_deleted_version_manifests.sql @@ -0,0 +1,133 @@ +-- Sweep soft-deleted app_versions that still have manifest rows or stale counters. +-- Touches a bounded batch so on_version_update re-runs cleanup_manifest. +-- Also zeros stale manifest_count when no rows remain. + +CREATE OR REPLACE FUNCTION "public"."sweep_deleted_version_manifests"("p_batch_size" integer DEFAULT 100) +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + stale_fixed bigint := 0; + requeued bigint := 0; +BEGIN + IF p_batch_size IS NULL OR p_batch_size < 1 THEN + p_batch_size := 100; + END IF; + + -- Fix stale counters: deleted versions with manifest_count > 0 but no rows. + WITH stale AS ( + SELECT av.id, av.app_id + FROM public.app_versions AS av + WHERE av.deleted = true + AND av.manifest_count > 0 + AND NOT EXISTS ( + SELECT 1 + FROM public.manifest AS m + WHERE m.app_version_id = av.id + ) + ORDER BY av.deleted_at NULLS LAST, av.id + LIMIT p_batch_size + ), + cleared AS ( + UPDATE public.app_versions AS av + SET manifest_count = 0, + manifest = NULL, + updated_at = now() + FROM stale + WHERE av.id = stale.id + RETURNING stale.app_id + ), + app_counts AS ( + SELECT app_id, COUNT(*)::int AS cleared_count + FROM cleared + GROUP BY app_id + ) + UPDATE public.apps AS a + SET manifest_bundle_count = GREATEST(a.manifest_bundle_count - app_counts.cleared_count, 0), + updated_at = now() + FROM app_counts + WHERE a.app_id = app_counts.app_id; + + GET DIAGNOSTICS stale_fixed = ROW_COUNT; + + -- Re-queue deleted versions that still have manifest rows by touching them. + -- on_version_update trigger enqueues cleanup when deleted_at is unchanged and + -- manifest_count > 0. + WITH candidates AS ( + SELECT m.app_version_id AS id + FROM public.manifest AS m + JOIN public.app_versions AS av + ON av.id = m.app_version_id + AND av.deleted = true + GROUP BY m.app_version_id + ORDER BY m.app_version_id + LIMIT p_batch_size + ) + UPDATE public.app_versions AS av + SET manifest_count = GREATEST(av.manifest_count, 1), + updated_at = now() + FROM candidates + WHERE av.id = candidates.id; + + GET DIAGNOSTICS requeued = ROW_COUNT; + + IF stale_fixed > 0 OR requeued > 0 THEN + RAISE NOTICE 'sweep_deleted_version_manifests: stale_counters=% requeued=%', stale_fixed, requeued; + END IF; + + RETURN requeued; +END; +$$; + +ALTER FUNCTION public.sweep_deleted_version_manifests(integer) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.sweep_deleted_version_manifests(integer) FROM PUBLIC; +GRANT ALL ON FUNCTION public.sweep_deleted_version_manifests(integer) TO service_role; + +COMMENT ON FUNCTION public.sweep_deleted_version_manifests(integer) IS + 'Bounded sweeper for soft-deleted versions with leftover manifest rows or stale manifest_count. Re-touches rows so on_version_update runs cleanup_manifest (DB delete + R2 trash).'; + +INSERT INTO public.cron_tasks ( + name, + description, + task_type, + target, + batch_size, + payload, + second_interval, + minute_interval, + hour_interval, + run_at_hour, + run_at_minute, + run_at_second, + run_on_dow, + run_on_day, + enabled, + healthcheck_url +) VALUES ( + 'sweep_deleted_version_manifests', + 'Re-queue soft-deleted versions that still have manifest rows; zero stale manifest_count', + 'function', + 'public.sweep_deleted_version_manifests(100)', + NULL, + NULL, + NULL, + 15, + NULL, + NULL, + NULL, + 0, + NULL, + NULL, + true, + NULL +) +ON CONFLICT (name) DO UPDATE +SET + description = EXCLUDED.description, + task_type = EXCLUDED.task_type, + target = EXCLUDED.target, + minute_interval = EXCLUDED.minute_interval, + enabled = EXCLUDED.enabled, + updated_at = now(); diff --git a/tests/on-version-update-cleanup.unit.test.ts b/tests/on-version-update-cleanup.unit.test.ts index 7b3a5aa299..92246d72b3 100644 --- a/tests/on-version-update-cleanup.unit.test.ts +++ b/tests/on-version-update-cleanup.unit.test.ts @@ -4,6 +4,8 @@ const { appVersionsMetaSelectEq, appVersionsMetaUpdate, appVersionsMetaUpdateEq, + callOrder, + checkIfExist, closeClient, createStatsMeta, deleteObject, @@ -16,15 +18,20 @@ const { pgQuery, supabaseAdmin, } = vi.hoisted(() => { + const callOrder: string[] = [] const appVersionsMetaSelectEq = vi.fn() const appVersionsMetaSelect = vi.fn(() => ({ eq: appVersionsMetaSelectEq })) const appVersionsMetaUpdateEq = vi.fn() const appVersionsMetaUpdate = vi.fn(() => ({ eq: appVersionsMetaUpdateEq })) - const manifestDeleteEq = vi.fn() + const manifestDeleteEq = vi.fn(async () => { + callOrder.push('db_delete_row') + return { error: null } + }) const manifestDelete = vi.fn(() => ({ eq: manifestDeleteEq })) const manifestReferenceMaybeSingle = vi.fn() const manifestReferenceLimit = vi.fn(() => ({ maybeSingle: manifestReferenceMaybeSingle })) - const manifestReferenceEqFileName = vi.fn(() => ({ limit: manifestReferenceLimit })) + const manifestReferenceNeq = vi.fn(() => ({ limit: manifestReferenceLimit })) + const manifestReferenceEqFileName = vi.fn(() => ({ neq: manifestReferenceNeq })) const manifestReferenceEqFileHash = vi.fn(() => ({ eq: manifestReferenceEqFileName })) const manifestSelect = vi.fn(() => ({ eq: manifestReferenceEqFileHash })) const supabaseFrom = vi.fn((table: string) => { @@ -43,12 +50,19 @@ const { return {} }) const manifestSelectWhere = vi.fn(async (): Promise => []) - const pgQuery = vi.fn(async () => ({ rows: [] })) + const pgQuery = vi.fn(async () => ({ rows: [], rowCount: 0 })) + const moveObjectToTrash = vi.fn(async () => { + callOrder.push('r2_trash') + return true + }) + const checkIfExist = vi.fn(async () => true) return { appVersionsMetaSelectEq, appVersionsMetaUpdate, appVersionsMetaUpdateEq, + callOrder, + checkIfExist, closeClient: vi.fn(), createStatsMeta: vi.fn(), deleteObject: vi.fn(), @@ -63,7 +77,7 @@ const { manifestDeleteEq, manifestReferenceMaybeSingle, manifestSelectWhere, - moveObjectToTrash: vi.fn(), + moveObjectToTrash, pgQuery, supabaseAdmin: vi.fn(() => ({ from: supabaseFrom })), supabaseFrom, @@ -73,6 +87,7 @@ const { vi.mock('../supabase/functions/_backend/utils/s3.ts', () => ({ getPath: vi.fn(), s3: { + checkIfExist, deleteObject, moveObjectToTrash, }, @@ -97,7 +112,7 @@ vi.mock('../supabase/functions/_backend/utils/logging.ts', () => ({ cloudlogErr: vi.fn(), })) -const { deleteIt } = await import('../supabase/functions/_backend/triggers/on_version_update.ts') +const { deleteIt, onVersionUpdateTestUtils } = await import('../supabase/functions/_backend/triggers/on_version_update.ts') function createContext() { return { @@ -111,6 +126,7 @@ function createVersion(overrides: Record = {}) { app_id: 'com.cleanup.test', id: 123, manifest: null, + manifest_count: 0, name: '1.0.0', r2_path: 'orgs/org-1/apps/com.cleanup.test/1.0.0.zip', storage_provider: 'r2', @@ -118,16 +134,45 @@ function createVersion(overrides: Record = {}) { } as any } +function mockSuccessfulDbFinalize() { + pgQuery.mockImplementation(async (sql: string) => { + if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') + return { rows: [], rowCount: 0 } + if (sql.includes('SELECT COUNT(*)')) + return { rows: [{ count: 0 }], rowCount: 1 } + if (sql.includes('WITH prev AS')) + return { rows: [], rowCount: 1 } + return { rows: [], rowCount: 0 } + }) +} + +function makeEntries(count: number) { + return Array.from({ length: count }, (_, i) => ({ + id: 1000 + i, + file_hash: `hash-${i}`, + file_name: `file-${i}.js`, + s3_path: `orgs/org-1/apps/com.cleanup.test/delta/file-${i}.js`, + })) +} + describe('on_version_update deleted version cleanup', () => { beforeEach(() => { vi.clearAllMocks() + callOrder.length = 0 deleteObject.mockResolvedValue(true) - moveObjectToTrash.mockResolvedValue(true) + moveObjectToTrash.mockImplementation(async () => { + callOrder.push('r2_trash') + return true + }) + checkIfExist.mockResolvedValue(true) createStatsMeta.mockResolvedValue({ error: null }) manifestSelectWhere.mockResolvedValue([]) - manifestDeleteEq.mockResolvedValue({ error: null }) manifestReferenceMaybeSingle.mockResolvedValue({ data: null, error: null }) - pgQuery.mockResolvedValue({ rows: [] }) + manifestDeleteEq.mockImplementation(async () => { + callOrder.push('db_delete_row') + return { error: null } + }) + mockSuccessfulDbFinalize() appVersionsMetaSelectEq.mockReturnValue({ single: vi.fn(async () => ({ data: { size: 1234 }, error: null })), }) @@ -139,44 +184,175 @@ describe('on_version_update deleted version cleanup', () => { expect(response.status).toBe(200) expect(moveObjectToTrash).toHaveBeenCalledWith(expect.anything(), 'orgs/org-1/apps/com.cleanup.test/1.0.0.zip') - expect(deleteObject).not.toHaveBeenCalled() expect(appVersionsMetaUpdate).toHaveBeenCalledWith({ size: 0 }) - expect(appVersionsMetaUpdateEq).toHaveBeenCalledWith('id', 123) - expect(createStatsMeta).toHaveBeenCalledWith(expect.anything(), 'com.cleanup.test', 123, -1234) }) - it('still clears stale metadata when the deleted version has no bundle path', async () => { - const response = await deleteIt(createContext(), createVersion({ r2_path: null })) + it('trashes R2 before deleting each manifest DB row', async () => { + manifestSelectWhere.mockResolvedValue(makeEntries(1)) + + await deleteIt(createContext(), createVersion({ r2_path: null, manifest_count: 1 })) + + expect(callOrder.indexOf('r2_trash')).toBeGreaterThanOrEqual(0) + expect(callOrder.indexOf('db_delete_row')).toBeGreaterThan(callOrder.indexOf('r2_trash')) + expect(pgQuery).toHaveBeenCalledWith('COMMIT') + }) + + it('does not delete DB rows when R2 trash fails', async () => { + manifestSelectWhere.mockResolvedValue(makeEntries(1)) + moveObjectToTrash.mockImplementation(async () => { + callOrder.push('r2_trash') + return false + }) + + await expect(deleteIt(createContext(), createVersion({ r2_path: null, manifest_count: 1 }))).rejects.toThrow( + 'Cannot move S3 object for deleted manifest file to trash', + ) + expect(callOrder).toContain('r2_trash') + expect(callOrder).not.toContain('db_delete_row') + expect(pgQuery).not.toHaveBeenCalledWith('COMMIT') + }) + + it('skips R2 trash when another version still references the file, then deletes the row', async () => { + manifestSelectWhere.mockResolvedValue(makeEntries(1)) + manifestReferenceMaybeSingle.mockResolvedValue({ data: { id: 999 }, error: null }) + + await deleteIt(createContext(), createVersion({ r2_path: null, manifest_count: 1 })) - expect(response.status).toBe(200) expect(moveObjectToTrash).not.toHaveBeenCalled() - expect(appVersionsMetaUpdate).toHaveBeenCalledWith({ size: 0 }) - expect(createStatsMeta).toHaveBeenCalledWith(expect.anything(), 'com.cleanup.test', 123, -1234) + expect(callOrder).toContain('db_delete_row') }) - it('moves unreferenced manifest files to trash instead of hard deleting them', async () => { - manifestSelectWhere.mockResolvedValue([{ - app_version_id: 123, - file_hash: 'manifest-hash', - file_name: 'index.js', - id: 456, - s3_path: 'orgs/org-1/apps/com.cleanup.test/manifest/index.js', - }]) + it('still clears manifests when version meta is missing', async () => { + appVersionsMetaSelectEq.mockReturnValue({ + single: vi.fn(async () => ({ data: null, error: { message: 'not found' } })), + }) + manifestSelectWhere.mockResolvedValue(makeEntries(1)) - const response = await deleteIt(createContext(), createVersion({ r2_path: null })) + const response = await deleteIt(createContext(), createVersion({ manifest_count: 1 })) expect(response.status).toBe(200) - expect(moveObjectToTrash).toHaveBeenCalledWith(expect.anything(), 'orgs/org-1/apps/com.cleanup.test/manifest/index.js') - expect(deleteObject).not.toHaveBeenCalled() - expect(pgQuery).toHaveBeenCalledWith('UPDATE app_versions SET manifest_count = 0, manifest = NULL WHERE id = $1', [123]) - expect(pgQuery).toHaveBeenCalledWith(expect.stringContaining('manifest_bundle_count = GREATEST(manifest_bundle_count - 1, 0)'), ['com.cleanup.test']) + expect(callOrder.indexOf('r2_trash')).toBeLessThan(callOrder.indexOf('db_delete_row')) + expect(moveObjectToTrash).toHaveBeenCalledWith(expect.anything(), 'orgs/org-1/apps/com.cleanup.test/1.0.0.zip') }) - it('keeps the queue retryable when moving the bundle to trash fails', async () => { - moveObjectToTrash.mockResolvedValue(false) + it('keeps the queue retryable when moving the bundle to trash fails after manifest cleanup', async () => { + manifestSelectWhere.mockResolvedValue(makeEntries(1)) + moveObjectToTrash.mockImplementation(async (_c: unknown, path: string) => { + callOrder.push(path.includes('.zip') ? 'bundle_trash' : 'r2_trash') + return !path.includes('.zip') + }) + + await expect(deleteIt(createContext(), createVersion({ manifest_count: 1 }))).rejects.toThrow( + 'Cannot move S3 object for deleted version to trash', + ) + expect(callOrder).toContain('r2_trash') + expect(callOrder).toContain('db_delete_row') + expect(pgQuery).toHaveBeenCalledWith('COMMIT') + }) + + it('throws when rows remain after the trash/delete pass', async () => { + manifestSelectWhere.mockResolvedValue(makeEntries(1)) + pgQuery.mockImplementation(async (sql: string) => { + if (sql === 'BEGIN' || sql === 'ROLLBACK') + return { rows: [], rowCount: 0 } + if (sql.includes('SELECT COUNT(*)')) + return { rows: [{ count: 2 }], rowCount: 1 } + return { rows: [], rowCount: 0 } + }) + + await expect(deleteIt(createContext(), createVersion({ r2_path: null, manifest_count: 1 }))).rejects.toThrow( + 'Manifest rows still present after trash/delete pass', + ) + expect(pgQuery).toHaveBeenCalledWith('ROLLBACK') + }) + + it('routes already-deleted versions with leftover counts to cleanup_manifest', () => { + expect(onVersionUpdateTestUtils.getDeletedVersionAction( + createVersion({ deleted_at: '2026-01-01T00:00:00Z', manifest_count: 3 }), + createVersion({ deleted_at: '2026-01-01T00:00:00Z', manifest_count: 3 }), + )).toBe('cleanup_manifest') + }) +}) + +describe('on_version_update manifest cleanup load', () => { + beforeEach(() => { + vi.clearAllMocks() + callOrder.length = 0 + createStatsMeta.mockResolvedValue({ error: null }) + manifestReferenceMaybeSingle.mockResolvedValue({ data: null, error: null }) + mockSuccessfulDbFinalize() + appVersionsMetaSelectEq.mockReturnValue({ + single: vi.fn(async () => ({ data: { size: 0 }, error: null })), + }) + appVersionsMetaUpdateEq.mockResolvedValue({ error: null }) + manifestDeleteEq.mockImplementation(async () => { + callOrder.push('db_delete_row') + return { error: null } + }) + moveObjectToTrash.mockImplementation(async () => { + callOrder.push('r2_trash') + return true + }) + }) + + it('handles 5000-file manifests with R2 before every DB delete and one final commit', async () => { + const entries = makeEntries(5000) + manifestSelectWhere.mockResolvedValue(entries) + + const response = await deleteIt(createContext(), createVersion({ r2_path: null, manifest_count: 5000 })) + + expect(response.status).toBe(200) + expect(moveObjectToTrash).toHaveBeenCalledTimes(5000) + expect(manifestDeleteEq).toHaveBeenCalledTimes(5000) + expect(pgQuery).toHaveBeenCalledWith('COMMIT') + + const trashIndexes = callOrder.map((v, i) => v === 'r2_trash' ? i : -1).filter(i => i >= 0) + const deleteIndexes = callOrder.map((v, i) => v === 'db_delete_row' ? i : -1).filter(i => i >= 0) + expect(trashIndexes).toHaveLength(5000) + expect(deleteIndexes).toHaveLength(5000) + // Every delete must be preceded by at least as many trash ops (batched concurrency). + expect(deleteIndexes[0]).toBeGreaterThan(trashIndexes[0]) + expect(deleteIndexes.at(-1)!).toBeGreaterThan(trashIndexes[0]) + }, 30_000) + + it('keeps remaining rows retryable when one file in a large batch fails trash', async () => { + const entries = makeEntries(200) + manifestSelectWhere.mockResolvedValue(entries) + moveObjectToTrash.mockImplementation(async (_c: unknown, path: string) => { + callOrder.push('r2_trash') + if (path.endsWith('file-150.js')) + return false + return true + }) + + await expect(deleteIt(createContext(), createVersion({ r2_path: null, manifest_count: 200 }))).rejects.toThrow( + 'Cannot move S3 object for deleted manifest file to trash', + ) + + // Failed file must not lose DB tracking. + const deletedIds = manifestDeleteEq.mock.calls.map(call => call[1]) + expect(deletedIds).not.toContain(1150) + expect(pgQuery).not.toHaveBeenCalledWith('COMMIT') + }, 30_000) + + it('does not finalize counters while rows remain after a partial failure', async () => { + manifestSelectWhere.mockResolvedValue(makeEntries(10)) + let deletes = 0 + manifestDeleteEq.mockImplementation(async () => { + deletes += 1 + callOrder.push('db_delete_row') + if (deletes >= 5) + return { error: { message: 'db down' } } + return { error: null } + }) + moveObjectToTrash.mockImplementation(async () => { + callOrder.push('r2_trash') + return true + }) - await expect(deleteIt(createContext(), createVersion())).rejects.toThrow('Cannot move S3 object for deleted version to trash') - expect(appVersionsMetaUpdate).not.toHaveBeenCalled() - expect(createStatsMeta).not.toHaveBeenCalled() + await expect(deleteIt(createContext(), createVersion({ r2_path: null, manifest_count: 10 }))).rejects.toThrow( + 'Cannot delete manifest row after R2 trash', + ) + expect(pgQuery).not.toHaveBeenCalledWith(expect.stringContaining('WITH prev AS')) }) }) diff --git a/tests/queue-consumer-message-shape.unit.test.ts b/tests/queue-consumer-message-shape.unit.test.ts index 51cd2b27b1..1ef24325dd 100644 --- a/tests/queue-consumer-message-shape.unit.test.ts +++ b/tests/queue-consumer-message-shape.unit.test.ts @@ -158,7 +158,9 @@ describe('queue_consumer legacy message compatibility', () => { expect(__queueConsumerTestUtils__.getQueueVisibilityTimeout('on_manifest_create')).toBe(900) expect(__queueConsumerTestUtils__.getQueueVisibilityTimeout('cron_email')).toBe(120) expect(__queueConsumerTestUtils__.getQueueVisibilityTimeout('on_version_update')).toBe(900) - expect(__queueConsumerTestUtils__.getQueueHttpTimeoutMs('on_version_update')).toBe(60_000) + expect(__queueConsumerTestUtils__.getQueueHttpTimeoutMs('on_version_update')).toBe(300_000) + expect(__queueConsumerTestUtils__.getQueueMaxReads('on_version_update')).toBe(30) + expect(__queueConsumerTestUtils__.getQueueMaxReads('on_manifest_create')).toBe(5) expect(__queueConsumerTestUtils__.getQueueHttpTimeoutMs('cron_email')).toBe(15_000) expect(__queueConsumerTestUtils__.shouldRunQueueSyncInBackground('on_manifest_create')).toBe(false) expect(__queueConsumerTestUtils__.shouldRunQueueSyncInBackground('cron_email')).toBe(true) From 927a526c87de3469c58665122f9f9fd8f88f05d5 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 22 Jul 2026 17:32:48 +0300 Subject: [PATCH 2/8] test(manifest): fix cleanup unit test typecheck Co-authored-by: Cursor --- tests/on-version-update-cleanup.unit.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/on-version-update-cleanup.unit.test.ts b/tests/on-version-update-cleanup.unit.test.ts index 92246d72b3..ab6c6eb50c 100644 --- a/tests/on-version-update-cleanup.unit.test.ts +++ b/tests/on-version-update-cleanup.unit.test.ts @@ -23,7 +23,7 @@ const { const appVersionsMetaSelect = vi.fn(() => ({ eq: appVersionsMetaSelectEq })) const appVersionsMetaUpdateEq = vi.fn() const appVersionsMetaUpdate = vi.fn(() => ({ eq: appVersionsMetaUpdateEq })) - const manifestDeleteEq = vi.fn(async () => { + const manifestDeleteEq = vi.fn(async (..._args: any[]) => { callOrder.push('db_delete_row') return { error: null } }) @@ -50,8 +50,8 @@ const { return {} }) const manifestSelectWhere = vi.fn(async (): Promise => []) - const pgQuery = vi.fn(async () => ({ rows: [], rowCount: 0 })) - const moveObjectToTrash = vi.fn(async () => { + const pgQuery = vi.fn(async (..._args: any[]) => ({ rows: [] as any[], rowCount: 0 })) + const moveObjectToTrash = vi.fn(async (..._args: any[]) => { callOrder.push('r2_trash') return true }) @@ -310,7 +310,6 @@ describe('on_version_update manifest cleanup load', () => { const deleteIndexes = callOrder.map((v, i) => v === 'db_delete_row' ? i : -1).filter(i => i >= 0) expect(trashIndexes).toHaveLength(5000) expect(deleteIndexes).toHaveLength(5000) - // Every delete must be preceded by at least as many trash ops (batched concurrency). expect(deleteIndexes[0]).toBeGreaterThan(trashIndexes[0]) expect(deleteIndexes.at(-1)!).toBeGreaterThan(trashIndexes[0]) }, 30_000) @@ -329,8 +328,7 @@ describe('on_version_update manifest cleanup load', () => { 'Cannot move S3 object for deleted manifest file to trash', ) - // Failed file must not lose DB tracking. - const deletedIds = manifestDeleteEq.mock.calls.map(call => call[1]) + const deletedIds = manifestDeleteEq.mock.calls.map(call => call[1] as number) expect(deletedIds).not.toContain(1150) expect(pgQuery).not.toHaveBeenCalledWith('COMMIT') }, 30_000) @@ -338,13 +336,13 @@ describe('on_version_update manifest cleanup load', () => { it('does not finalize counters while rows remain after a partial failure', async () => { manifestSelectWhere.mockResolvedValue(makeEntries(10)) let deletes = 0 - manifestDeleteEq.mockImplementation(async () => { + manifestDeleteEq.mockImplementation((async () => { deletes += 1 callOrder.push('db_delete_row') if (deletes >= 5) return { error: { message: 'db down' } } return { error: null } - }) + }) as any) moveObjectToTrash.mockImplementation(async () => { callOrder.push('r2_trash') return true From fdff6e91f1c3dfc7fe9f1ab32c9050f8309fe34f Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 22 Jul 2026 17:52:32 +0300 Subject: [PATCH 3/8] fix(manifest): address cleanup review (presence, locks, SSL) Only skip R2 trash on a definitive absent HEAD, serialize shared-hash cleanup with advisory locks, pin reclaim-script TLS verification, and bound the sweeper scan with deleted-version indexes. Co-authored-by: Cursor --- .../supabase/.branches/_current_branch | 1 + .../c85d6572/supabase/.gitignore | 1 + .../c85d6572/supabase/.temp/cli-latest | 1 + .../c85d6572/supabase/config.toml | 442 ++++++++++++++++++ .../c85d6572/supabase/functions | 1 + .../c85d6572/supabase/migration_guide.md | 1 + .../c85d6572/supabase/migrations | 1 + .../c85d6572/supabase/schemas | 1 + .../c85d6572/supabase/seed.sql | 1 + .../c85d6572/supabase/templates | 1 + .../c85d6572/supabase/tests | 1 + .../supabase-worktrees/c85d6572/templates | 1 + scripts/reclaim_deleted_version_manifests.ts | 19 +- .../_backend/triggers/on_version_update.ts | 82 ++-- supabase/functions/_backend/utils/s3.ts | 52 ++- ...095157_sweep_deleted_version_manifests.sql | 28 +- tests/on-version-update-cleanup.unit.test.ts | 209 ++++----- 17 files changed, 689 insertions(+), 154 deletions(-) create mode 100644 .context/supabase-worktrees/c85d6572/supabase/.branches/_current_branch create mode 120000 .context/supabase-worktrees/c85d6572/supabase/.gitignore create mode 100644 .context/supabase-worktrees/c85d6572/supabase/.temp/cli-latest create mode 100644 .context/supabase-worktrees/c85d6572/supabase/config.toml create mode 120000 .context/supabase-worktrees/c85d6572/supabase/functions create mode 120000 .context/supabase-worktrees/c85d6572/supabase/migration_guide.md create mode 120000 .context/supabase-worktrees/c85d6572/supabase/migrations create mode 120000 .context/supabase-worktrees/c85d6572/supabase/schemas create mode 120000 .context/supabase-worktrees/c85d6572/supabase/seed.sql create mode 120000 .context/supabase-worktrees/c85d6572/supabase/templates create mode 120000 .context/supabase-worktrees/c85d6572/supabase/tests create mode 120000 .context/supabase-worktrees/c85d6572/templates diff --git a/.context/supabase-worktrees/c85d6572/supabase/.branches/_current_branch b/.context/supabase-worktrees/c85d6572/supabase/.branches/_current_branch new file mode 100644 index 0000000000..88d050b190 --- /dev/null +++ b/.context/supabase-worktrees/c85d6572/supabase/.branches/_current_branch @@ -0,0 +1 @@ +main \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/.gitignore b/.context/supabase-worktrees/c85d6572/supabase/.gitignore new file mode 120000 index 0000000000..238aa65334 --- /dev/null +++ b/.context/supabase-worktrees/c85d6572/supabase/.gitignore @@ -0,0 +1 @@ +/Users/martindonadieu/Projects/capgo_all/capgo/supabase/.gitignore \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/.temp/cli-latest b/.context/supabase-worktrees/c85d6572/supabase/.temp/cli-latest new file mode 100644 index 0000000000..8403edf467 --- /dev/null +++ b/.context/supabase-worktrees/c85d6572/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.109.1 \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/config.toml b/.context/supabase-worktrees/c85d6572/supabase/config.toml new file mode 100644 index 0000000000..839ee33c5a --- /dev/null +++ b/.context/supabase-worktrees/c85d6572/supabase/config.toml @@ -0,0 +1,442 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "capgo-app-c85d6572" + +[api] +enabled = true +# Port to use for the API URL. +port = 55411 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false + +[db] +# Port to use for the local database URL. +port = 55412 +# Port used by db diff command to initialize the shadow database. +shadow_port = 55410 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 55419 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[realtime] +enabled = false +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 55413 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = false +# Port to use for the email testing server web interface. +port = 55414 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = false + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = false +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[auth.email.template.confirmation] +subject = "[Capgo]: Confirm your Signup to Capgo.app" +content_path = "./templates/confirmation.html" + +[auth.email.template.invite] +subject = "[Capgo]: You have been invited to Capgo.app" +content_path = "./templates/invite.html" + +[auth.email.template.magic_link] +subject = "[Capgo]: Magic Link to log into your Capgo.app account" +content_path = "./templates/magic_link.html" + +[auth.email.template.email_change] +subject = "[Capgo]: Confirm change of your Email with Capgo.app account" +content_path = "./templates/email_change.html" + +[auth.email.template.recovery] +subject = "[Capgo]: Instructions to change your Capgo.app password" +content_path = "./templates/recovery.html" + +[auth.email.template.reauthentication] +subject = "[Capgo]: Confirm Reauthentication of your Capgo.app account" +content_path = "./templates/reauthentication.html" + +[auth.email.notification.password_changed] +enabled = true +subject = "[Capgo]: Your Capgo.app password has been changed" +content_path = "./templates/password_changed_notification.html" + +[auth.email.notification.email_changed] +enabled = true +subject = "[Capgo]: Your Capgo.app email address has been changed" +content_path = "./templates/email_changed_notification.html" + +[auth.email.notification.mfa_factor_enrolled] +enabled = true +subject = "[Capgo]: New multi-factor authentication method added to your Capgo.app account" +content_path = "./templates/mfa_factor_enrolled_notification.html" + +[auth.email.notification.mfa_factor_unenrolled] +enabled = true +subject = "[Capgo]: Multi-factor authentication method removed from your Capgo.app account" +content_path = "./templates/mfa_factor_unenrolled_notification.html" + +# Custom email templates kept in ./templates for consistency, but not managed by Supabase. +# These are sent through Bento: +# - invite_new_user_to_org.html +# subject = "[Capgo]: Join {{ event.details.org_name }} org & create account" +# - invite_existing_user_to_org.html +# subject = "[Capgo]: Join {{ event.details.org_name }} with your account" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = false +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +[edge_runtime] +deno_version = 1 +enabled = true +# Configure one of the supported request policies: `oneshot`, `per_worker`. +# Use `oneshot` for hot reload, or `per_worker` for load testing. +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8192 + +# Use these configurations to customize your Edge Function. +# [functions.MY_FUNCTION_NAME] +# enabled = true +# verify_jwt = true +# import_map = "./functions/MY_FUNCTION_NAME/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +# entrypoint = "./functions/MY_FUNCTION_NAME/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/MY_FUNCTION_NAME/*.html" ] + +[analytics] +enabled = false +port = 55417 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + + +# PUBLIC ENDPOINTS + +[functions.ok] +verify_jwt = false +import_map = "./functions/deno.json" + +[functions.bundle] +verify_jwt = false +import_map = "./functions/deno.json" + +[functions.device] +verify_jwt = false +import_map = "./functions/deno.json" + +[functions.channel] +verify_jwt = false +import_map = "./functions/deno.json" + +[functions.organization] +verify_jwt = false +import_map = "./functions/deno.json" + +[functions.app] +verify_jwt = false +import_map = "./functions/deno.json" + +[functions.plugin_regions] +verify_jwt = false +import_map = "./functions/deno.json" + +[functions.build] +verify_jwt = false +import_map = "./functions/deno.json" + +[functions.notifications] +verify_jwt = false +import_map = "./functions/deno.json" + +# PLUGIN ENDPOINTS + +[functions.channel_self] +verify_jwt = false +import_map = "./functions/deno.json" + +[functions.updates] +verify_jwt = false +import_map = "./functions/deno.json" + +[functions.updates_debug] +verify_jwt = false +import_map = "./functions/deno.json" + +[functions.stats] +verify_jwt = false +import_map = "./functions/deno.json" + +# PRIVATE ENDPOINTS + +[functions.private] +verify_jwt = false +import_map = "./functions/deno.json" + +# STATISTICS ENDPOINTS +[functions.statistics] +verify_jwt = false +import_map = "./functions/deno.json" + +# TRIGGERS ENDPOINTS + +[functions.triggers] +verify_jwt = false +import_map = "./functions/deno.json" + +[functions.apikey] +verify_jwt = false +import_map = "./functions/deno.json" + +# FILES ENDPOINTS + +[functions.files] +verify_jwt = false +import_map = "./functions/deno.json" + +# WEBHOOKS ENDPOINTS + +[functions.webhooks] +verify_jwt = false +import_map = "./functions/deno.json" + +# Replication + +[functions.replication] +verify_jwt = false +import_map = "./functions/deno.json" + +# Check CPU Usage + +[functions.check_cpu_usage] +verify_jwt = false +import_map = "./functions/deno.json" diff --git a/.context/supabase-worktrees/c85d6572/supabase/functions b/.context/supabase-worktrees/c85d6572/supabase/functions new file mode 120000 index 0000000000..0afbcbbe17 --- /dev/null +++ b/.context/supabase-worktrees/c85d6572/supabase/functions @@ -0,0 +1 @@ +/Users/martindonadieu/Projects/capgo_all/capgo/supabase/functions \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/migration_guide.md b/.context/supabase-worktrees/c85d6572/supabase/migration_guide.md new file mode 120000 index 0000000000..dc745ce9b7 --- /dev/null +++ b/.context/supabase-worktrees/c85d6572/supabase/migration_guide.md @@ -0,0 +1 @@ +/Users/martindonadieu/Projects/capgo_all/capgo/supabase/migration_guide.md \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/migrations b/.context/supabase-worktrees/c85d6572/supabase/migrations new file mode 120000 index 0000000000..9f36c17e78 --- /dev/null +++ b/.context/supabase-worktrees/c85d6572/supabase/migrations @@ -0,0 +1 @@ +/Users/martindonadieu/Projects/capgo_all/capgo/supabase/migrations \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/schemas b/.context/supabase-worktrees/c85d6572/supabase/schemas new file mode 120000 index 0000000000..35e4c98d83 --- /dev/null +++ b/.context/supabase-worktrees/c85d6572/supabase/schemas @@ -0,0 +1 @@ +/Users/martindonadieu/Projects/capgo_all/capgo/supabase/schemas \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/seed.sql b/.context/supabase-worktrees/c85d6572/supabase/seed.sql new file mode 120000 index 0000000000..3f9f6f4f39 --- /dev/null +++ b/.context/supabase-worktrees/c85d6572/supabase/seed.sql @@ -0,0 +1 @@ +/Users/martindonadieu/Projects/capgo_all/capgo/supabase/seed.sql \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/templates b/.context/supabase-worktrees/c85d6572/supabase/templates new file mode 120000 index 0000000000..d86886ea2b --- /dev/null +++ b/.context/supabase-worktrees/c85d6572/supabase/templates @@ -0,0 +1 @@ +/Users/martindonadieu/Projects/capgo_all/capgo/supabase/templates \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/tests b/.context/supabase-worktrees/c85d6572/supabase/tests new file mode 120000 index 0000000000..c6ef97f3c4 --- /dev/null +++ b/.context/supabase-worktrees/c85d6572/supabase/tests @@ -0,0 +1 @@ +/Users/martindonadieu/Projects/capgo_all/capgo/supabase/tests \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/templates b/.context/supabase-worktrees/c85d6572/templates new file mode 120000 index 0000000000..d86886ea2b --- /dev/null +++ b/.context/supabase-worktrees/c85d6572/templates @@ -0,0 +1 @@ +/Users/martindonadieu/Projects/capgo_all/capgo/supabase/templates \ No newline at end of file diff --git a/scripts/reclaim_deleted_version_manifests.ts b/scripts/reclaim_deleted_version_manifests.ts index 374a959567..ba16250369 100644 --- a/scripts/reclaim_deleted_version_manifests.ts +++ b/scripts/reclaim_deleted_version_manifests.ts @@ -95,9 +95,26 @@ async function moveToTrash(s3: S3Client, bucket: string, key: string) { })) } +function shouldAllowSelfSignedPgCertificate(env: Record, databaseUrl: string) { + const rejectUnauthorized = env.PG_SSL_REJECT_UNAUTHORIZED?.trim() + if (rejectUnauthorized === '0') + return true + if (rejectUnauthorized === '1') + return false + // Local/docker URLs keep default Node TLS verification off only when explicitly local. + return databaseUrl.includes('localhost') || databaseUrl.includes('127.0.0.1') +} + async function main() { const env = await loadEnv(ENV_FILE) - const db = new pg.Client({ connectionString: requireDbUrl(env), ssl: { rejectUnauthorized: false } }) + const databaseUrl = requireDbUrl(env) + const usesLocalDatabase = databaseUrl.includes('localhost') || databaseUrl.includes('127.0.0.1') + const db = new pg.Client({ + connectionString: databaseUrl, + ssl: usesLocalDatabase + ? false + : { rejectUnauthorized: !shouldAllowSelfSignedPgCertificate(env, databaseUrl) }, + }) await db.connect() const s3 = new S3Client({ diff --git a/supabase/functions/_backend/triggers/on_version_update.ts b/supabase/functions/_backend/triggers/on_version_update.ts index a0fcebf7c0..5859fbb987 100644 --- a/supabase/functions/_backend/triggers/on_version_update.ts +++ b/supabase/functions/_backend/triggers/on_version_update.ts @@ -316,7 +316,7 @@ async function updateIt(c: Context, record: Database['public']['Tables']['app_ve return c.json(BRES) } -const MANIFEST_TRASH_CONCURRENCY = 50 +const MANIFEST_TRASH_CONCURRENCY = 10 type ManifestCleanupEntry = { id: number @@ -356,45 +356,55 @@ async function deleteManifest(c: Context, record: Database['public']['Tables'][' for (let i = 0; i < manifestEntries.length; i += MANIFEST_TRASH_CONCURRENCY) { const batch = manifestEntries.slice(i, i + MANIFEST_TRASH_CONCURRENCY) await Promise.all(batch.map(async (entry) => { - if (entry.s3_path) { - const { data: stillReferenced, error: refError } = await supabaseAdmin(c) - .from('manifest') - .select('id') - .eq('file_hash', entry.file_hash) - .eq('file_name', entry.file_name) - .neq('app_version_id', record.id) - .limit(1) - .maybeSingle() - - if (refError) { - throw simpleError('cannot_check_manifest_references', 'Cannot check manifest file references before trash', { - id: entry.id, - s3_path: entry.s3_path, - }, refError) - } + const entryPg = getPgClient(c, false) + try { + await entryPg.query('BEGIN') + // Serialize shared-hash cleanup across concurrent deleted versions. + await entryPg.query( + `SELECT pg_advisory_xact_lock(hashtextextended($1 || chr(0) || $2, 0))`, + [entry.file_hash, entry.file_name], + ) - if (!stillReferenced) { - const moved = await s3.moveObjectToTrash(c, entry.s3_path) - if (!moved) { - throw simpleError('cannot_move_manifest_s3_to_trash', 'Cannot move S3 object for deleted manifest file to trash', { - id: entry.id, - s3_path: entry.s3_path, - }) + if (entry.s3_path) { + const refs = await entryPg.query( + `SELECT 1 AS ok + FROM public.manifest + WHERE file_hash = $1 + AND file_name = $2 + AND app_version_id <> $3 + LIMIT 1`, + [entry.file_hash, entry.file_name, record.id], + ) + + if (refs.rows.length === 0) { + const moved = await s3.moveObjectToTrash(c, entry.s3_path) + if (!moved) { + throw simpleError('cannot_move_manifest_s3_to_trash', 'Cannot move S3 object for deleted manifest file to trash', { + id: entry.id, + s3_path: entry.s3_path, + }) + } } } - } - // Only delete the DB row after R2 is handled (or shared and kept). - const { error: deleteError } = await supabaseAdmin(c) - .from('manifest') - .delete() - .eq('id', entry.id) - - if (deleteError) { - throw simpleError('cannot_delete_manifest_row', 'Cannot delete manifest row after R2 trash', { - id: entry.id, - s3_path: entry.s3_path, - }, deleteError) + // Only delete the DB row after R2 is handled (or shared and kept). + await entryPg.query( + `DELETE FROM public.manifest WHERE id = $1`, + [entry.id], + ) + await entryPg.query('COMMIT') + } + catch (error) { + try { + await entryPg.query('ROLLBACK') + } + catch { + // ignore rollback errors + } + throw error + } + finally { + await closeClient(c, entryPg) } })) } diff --git a/supabase/functions/_backend/utils/s3.ts b/supabase/functions/_backend/utils/s3.ts index d94ccf3fef..ada7e37b31 100644 --- a/supabase/functions/_backend/utils/s3.ts +++ b/supabase/functions/_backend/utils/s3.ts @@ -141,18 +141,60 @@ function shouldUseSizeRangeFallback(size: number, headError: unknown): boolean { return !size && !isMissingObjectError(headError) } +type ObjectPresence = 'present' | 'absent' | 'unknown' + +async function getObjectPresence(c: Context, fileId: string | null): Promise { + if (!fileId) + return 'absent' + + try { + const client = initS3(c) + const url = await client.getPresignedUrl('HEAD', fileId) + const response = await fetch(url, { method: 'HEAD' }) + await response.body?.cancel() + + if (response.status === 404) + return 'absent' + if (response.status === 200) + return 'present' + + cloudlogErr({ + requestId: c.get('requestId'), + message: 'getObjectPresence unexpected HEAD status', + fileId, + status: response.status, + statusText: response.statusText, + }) + return 'unknown' + } + catch (error) { + if (isMissingObjectError(error)) + return 'absent' + cloudlogErr({ + requestId: c.get('requestId'), + message: 'getObjectPresence failed', + fileId, + error: serializeStorageError(error), + }) + return 'unknown' + } +} + async function moveObjectToTrash(c: Context, fileId: string) { if (fileId.startsWith(R2_TRASH_PREFIX)) return true - // Never delete tracking before we know the object state. - // Exists → copy into lifecycle trash then remove original. - // Missing → treat as already gone so callers can drop DB rows safely. - const exists = await checkIfExist(c, fileId) - if (!exists) { + // Only skip copy on a definitive absent object. Unknown HEAD must fail closed + // so callers keep DB tracking until trash succeeds. + const presence = await getObjectPresence(c, fileId) + if (presence === 'absent') { cloudlog({ requestId: c.get('requestId'), message: 'R2 object missing before trash move, skip copy', fileId }) return true } + if (presence === 'unknown') { + cloudlogErr({ requestId: c.get('requestId'), message: 'R2 object presence unknown, refuse trash skip', fileId }) + return false + } const client = initS3(c) const trashPath = getTrashPath(fileId) diff --git a/supabase/migrations/20260722095157_sweep_deleted_version_manifests.sql b/supabase/migrations/20260722095157_sweep_deleted_version_manifests.sql index eebc34e068..4916bdde51 100644 --- a/supabase/migrations/20260722095157_sweep_deleted_version_manifests.sql +++ b/supabase/migrations/20260722095157_sweep_deleted_version_manifests.sql @@ -1,4 +1,13 @@ --- Sweep soft-deleted app_versions that still have manifest rows or stale counters. +-- Sweep soft-deleted app_versions + +CREATE INDEX IF NOT EXISTS idx_app_versions_deleted_with_manifest + ON public.app_versions (id) + WHERE deleted = true AND manifest_count > 0; + +CREATE INDEX IF NOT EXISTS idx_app_versions_deleted_at_id + ON public.app_versions (deleted_at, id) + WHERE deleted = true; + that still have manifest rows or stale counters. -- Touches a bounded batch so on_version_update re-runs cleanup_manifest. -- Also zeros stale manifest_count when no rows remain. @@ -55,14 +64,17 @@ BEGIN -- Re-queue deleted versions that still have manifest rows by touching them. -- on_version_update trigger enqueues cleanup when deleted_at is unchanged and -- manifest_count > 0. + -- Start from deleted versions (bounded) and probe manifest via app_version_id index. WITH candidates AS ( - SELECT m.app_version_id AS id - FROM public.manifest AS m - JOIN public.app_versions AS av - ON av.id = m.app_version_id - AND av.deleted = true - GROUP BY m.app_version_id - ORDER BY m.app_version_id + SELECT av.id + FROM public.app_versions AS av + WHERE av.deleted = true + AND EXISTS ( + SELECT 1 + FROM public.manifest AS m + WHERE m.app_version_id = av.id + ) + ORDER BY av.deleted_at NULLS LAST, av.id LIMIT p_batch_size ) UPDATE public.app_versions AS av diff --git a/tests/on-version-update-cleanup.unit.test.ts b/tests/on-version-update-cleanup.unit.test.ts index ab6c6eb50c..3de90ada57 100644 --- a/tests/on-version-update-cleanup.unit.test.ts +++ b/tests/on-version-update-cleanup.unit.test.ts @@ -5,14 +5,11 @@ const { appVersionsMetaUpdate, appVersionsMetaUpdateEq, callOrder, - checkIfExist, closeClient, createStatsMeta, deleteObject, getDrizzleClient, getPgClient, - manifestDeleteEq, - manifestReferenceMaybeSingle, manifestSelectWhere, moveObjectToTrash, pgQuery, @@ -23,17 +20,6 @@ const { const appVersionsMetaSelect = vi.fn(() => ({ eq: appVersionsMetaSelectEq })) const appVersionsMetaUpdateEq = vi.fn() const appVersionsMetaUpdate = vi.fn(() => ({ eq: appVersionsMetaUpdateEq })) - const manifestDeleteEq = vi.fn(async (..._args: any[]) => { - callOrder.push('db_delete_row') - return { error: null } - }) - const manifestDelete = vi.fn(() => ({ eq: manifestDeleteEq })) - const manifestReferenceMaybeSingle = vi.fn() - const manifestReferenceLimit = vi.fn(() => ({ maybeSingle: manifestReferenceMaybeSingle })) - const manifestReferenceNeq = vi.fn(() => ({ limit: manifestReferenceLimit })) - const manifestReferenceEqFileName = vi.fn(() => ({ neq: manifestReferenceNeq })) - const manifestReferenceEqFileHash = vi.fn(() => ({ eq: manifestReferenceEqFileName })) - const manifestSelect = vi.fn(() => ({ eq: manifestReferenceEqFileHash })) const supabaseFrom = vi.fn((table: string) => { if (table === 'app_versions_meta') { return { @@ -41,28 +27,40 @@ const { update: appVersionsMetaUpdate, } } - if (table === 'manifest') { - return { - delete: manifestDelete, - select: manifestSelect, - } - } return {} }) const manifestSelectWhere = vi.fn(async (): Promise => []) - const pgQuery = vi.fn(async (..._args: any[]) => ({ rows: [] as any[], rowCount: 0 })) + const pgQuery = vi.fn(async (sql: string, params?: any[]) => { + if (sql === 'BEGIN') + callOrder.push('begin') + if (sql.includes('pg_advisory_xact_lock')) + callOrder.push('lock') + if (sql.includes('SELECT 1 AS ok')) + return { rows: [], rowCount: 0 } + if (sql.includes('DELETE FROM public.manifest WHERE id')) { + callOrder.push(`db_delete_row:${params?.[0]}`) + return { rows: [], rowCount: 1 } + } + if (sql === 'COMMIT') + callOrder.push('commit_entry') + if (sql === 'ROLLBACK') + callOrder.push('rollback_entry') + if (sql.includes('SELECT COUNT(*)')) + return { rows: [{ count: 0 }], rowCount: 1 } + if (sql.includes('WITH prev AS')) + return { rows: [], rowCount: 1 } + return { rows: [], rowCount: 0 } + }) const moveObjectToTrash = vi.fn(async (..._args: any[]) => { callOrder.push('r2_trash') return true }) - const checkIfExist = vi.fn(async () => true) return { appVersionsMetaSelectEq, appVersionsMetaUpdate, appVersionsMetaUpdateEq, callOrder, - checkIfExist, closeClient: vi.fn(), createStatsMeta: vi.fn(), deleteObject: vi.fn(), @@ -74,20 +72,16 @@ const { })), })), getPgClient: vi.fn(() => ({ query: pgQuery })), - manifestDeleteEq, - manifestReferenceMaybeSingle, manifestSelectWhere, moveObjectToTrash, pgQuery, supabaseAdmin: vi.fn(() => ({ from: supabaseFrom })), - supabaseFrom, } }) vi.mock('../supabase/functions/_backend/utils/s3.ts', () => ({ getPath: vi.fn(), s3: { - checkIfExist, deleteObject, moveObjectToTrash, }, @@ -134,18 +128,6 @@ function createVersion(overrides: Record = {}) { } as any } -function mockSuccessfulDbFinalize() { - pgQuery.mockImplementation(async (sql: string) => { - if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') - return { rows: [], rowCount: 0 } - if (sql.includes('SELECT COUNT(*)')) - return { rows: [{ count: 0 }], rowCount: 1 } - if (sql.includes('WITH prev AS')) - return { rows: [], rowCount: 1 } - return { rows: [], rowCount: 0 } - }) -} - function makeEntries(count: number) { return Array.from({ length: count }, (_, i) => ({ id: 1000 + i, @@ -164,15 +146,29 @@ describe('on_version_update deleted version cleanup', () => { callOrder.push('r2_trash') return true }) - checkIfExist.mockResolvedValue(true) createStatsMeta.mockResolvedValue({ error: null }) manifestSelectWhere.mockResolvedValue([]) - manifestReferenceMaybeSingle.mockResolvedValue({ data: null, error: null }) - manifestDeleteEq.mockImplementation(async () => { - callOrder.push('db_delete_row') - return { error: null } + pgQuery.mockImplementation(async (sql: string, params?: any[]) => { + if (sql === 'BEGIN') + callOrder.push('begin') + if (sql.includes('pg_advisory_xact_lock')) + callOrder.push('lock') + if (sql.includes('SELECT 1 AS ok')) + return { rows: [], rowCount: 0 } + if (sql.includes('DELETE FROM public.manifest WHERE id')) { + callOrder.push(`db_delete_row:${params?.[0]}`) + return { rows: [], rowCount: 1 } + } + if (sql === 'COMMIT') + callOrder.push('commit_entry') + if (sql === 'ROLLBACK') + callOrder.push('rollback_entry') + if (sql.includes('SELECT COUNT(*)')) + return { rows: [{ count: 0 }], rowCount: 1 } + if (sql.includes('WITH prev AS')) + return { rows: [], rowCount: 1 } + return { rows: [], rowCount: 0 } }) - mockSuccessfulDbFinalize() appVersionsMetaSelectEq.mockReturnValue({ single: vi.fn(async () => ({ data: { size: 1234 }, error: null })), }) @@ -181,20 +177,20 @@ describe('on_version_update deleted version cleanup', () => { it('moves the bundle to trash and clears stored size for soft-deleted versions', async () => { const response = await deleteIt(createContext(), createVersion()) - expect(response.status).toBe(200) expect(moveObjectToTrash).toHaveBeenCalledWith(expect.anything(), 'orgs/org-1/apps/com.cleanup.test/1.0.0.zip') expect(appVersionsMetaUpdate).toHaveBeenCalledWith({ size: 0 }) }) - it('trashes R2 before deleting each manifest DB row', async () => { + it('locks, trashes R2, then deletes each manifest DB row', async () => { manifestSelectWhere.mockResolvedValue(makeEntries(1)) await deleteIt(createContext(), createVersion({ r2_path: null, manifest_count: 1 })) - expect(callOrder.indexOf('r2_trash')).toBeGreaterThanOrEqual(0) - expect(callOrder.indexOf('db_delete_row')).toBeGreaterThan(callOrder.indexOf('r2_trash')) - expect(pgQuery).toHaveBeenCalledWith('COMMIT') + expect(callOrder).toContain('lock') + expect(callOrder.indexOf('r2_trash')).toBeGreaterThan(callOrder.indexOf('lock')) + expect(callOrder.indexOf('db_delete_row:1000')).toBeGreaterThan(callOrder.indexOf('r2_trash')) + expect(pgQuery).toHaveBeenCalledWith(expect.stringContaining('WITH prev AS'), expect.any(Array)) }) it('does not delete DB rows when R2 trash fails', async () => { @@ -208,18 +204,36 @@ describe('on_version_update deleted version cleanup', () => { 'Cannot move S3 object for deleted manifest file to trash', ) expect(callOrder).toContain('r2_trash') - expect(callOrder).not.toContain('db_delete_row') - expect(pgQuery).not.toHaveBeenCalledWith('COMMIT') + expect(callOrder.some(v => v.startsWith('db_delete_row:'))).toBe(false) + expect(callOrder).toContain('rollback_entry') }) it('skips R2 trash when another version still references the file, then deletes the row', async () => { manifestSelectWhere.mockResolvedValue(makeEntries(1)) - manifestReferenceMaybeSingle.mockResolvedValue({ data: { id: 999 }, error: null }) + pgQuery.mockImplementation((async (sql: string, params?: any[]) => { + if (sql === 'BEGIN') + callOrder.push('begin') + if (sql.includes('pg_advisory_xact_lock')) + callOrder.push('lock') + if (sql.includes('SELECT 1 AS ok')) + return { rows: [{ ok: 1 }], rowCount: 1 } + if (sql.includes('DELETE FROM public.manifest WHERE id')) { + callOrder.push(`db_delete_row:${params?.[0]}`) + return { rows: [], rowCount: 1 } + } + if (sql === 'COMMIT') + callOrder.push('commit_entry') + if (sql.includes('SELECT COUNT(*)')) + return { rows: [{ count: 0 }], rowCount: 1 } + if (sql.includes('WITH prev AS')) + return { rows: [], rowCount: 1 } + return { rows: [], rowCount: 0 } + }) as any) await deleteIt(createContext(), createVersion({ r2_path: null, manifest_count: 1 })) expect(moveObjectToTrash).not.toHaveBeenCalled() - expect(callOrder).toContain('db_delete_row') + expect(callOrder).toContain('db_delete_row:1000') }) it('still clears manifests when version meta is missing', async () => { @@ -231,7 +245,7 @@ describe('on_version_update deleted version cleanup', () => { const response = await deleteIt(createContext(), createVersion({ manifest_count: 1 })) expect(response.status).toBe(200) - expect(callOrder.indexOf('r2_trash')).toBeLessThan(callOrder.indexOf('db_delete_row')) + expect(callOrder.indexOf('r2_trash')).toBeLessThan(callOrder.indexOf('db_delete_row:1000')) expect(moveObjectToTrash).toHaveBeenCalledWith(expect.anything(), 'orgs/org-1/apps/com.cleanup.test/1.0.0.zip') }) @@ -246,15 +260,26 @@ describe('on_version_update deleted version cleanup', () => { 'Cannot move S3 object for deleted version to trash', ) expect(callOrder).toContain('r2_trash') - expect(callOrder).toContain('db_delete_row') - expect(pgQuery).toHaveBeenCalledWith('COMMIT') + expect(callOrder).toContain('db_delete_row:1000') }) it('throws when rows remain after the trash/delete pass', async () => { manifestSelectWhere.mockResolvedValue(makeEntries(1)) - pgQuery.mockImplementation(async (sql: string) => { - if (sql === 'BEGIN' || sql === 'ROLLBACK') + pgQuery.mockImplementation(async (sql: string, params?: any[]) => { + if (sql === 'BEGIN') + callOrder.push('begin') + if (sql.includes('pg_advisory_xact_lock')) + callOrder.push('lock') + if (sql.includes('SELECT 1 AS ok')) return { rows: [], rowCount: 0 } + if (sql.includes('DELETE FROM public.manifest WHERE id')) { + callOrder.push(`db_delete_row:${params?.[0]}`) + return { rows: [], rowCount: 1 } + } + if (sql === 'COMMIT') + callOrder.push('commit_entry') + if (sql === 'ROLLBACK') + callOrder.push('rollback_entry') if (sql.includes('SELECT COUNT(*)')) return { rows: [{ count: 2 }], rowCount: 1 } return { rows: [], rowCount: 0 } @@ -263,7 +288,7 @@ describe('on_version_update deleted version cleanup', () => { await expect(deleteIt(createContext(), createVersion({ r2_path: null, manifest_count: 1 }))).rejects.toThrow( 'Manifest rows still present after trash/delete pass', ) - expect(pgQuery).toHaveBeenCalledWith('ROLLBACK') + expect(callOrder).toContain('rollback_entry') }) it('routes already-deleted versions with leftover counts to cleanup_manifest', () => { @@ -279,44 +304,42 @@ describe('on_version_update manifest cleanup load', () => { vi.clearAllMocks() callOrder.length = 0 createStatsMeta.mockResolvedValue({ error: null }) - manifestReferenceMaybeSingle.mockResolvedValue({ data: null, error: null }) - mockSuccessfulDbFinalize() appVersionsMetaSelectEq.mockReturnValue({ single: vi.fn(async () => ({ data: { size: 0 }, error: null })), }) appVersionsMetaUpdateEq.mockResolvedValue({ error: null }) - manifestDeleteEq.mockImplementation(async () => { - callOrder.push('db_delete_row') - return { error: null } - }) moveObjectToTrash.mockImplementation(async () => { callOrder.push('r2_trash') return true }) + pgQuery.mockImplementation(async (sql: string, params?: any[]) => { + if (sql.includes('SELECT 1 AS ok')) + return { rows: [], rowCount: 0 } + if (sql.includes('DELETE FROM public.manifest WHERE id')) { + callOrder.push(`db_delete_row:${params?.[0]}`) + return { rows: [], rowCount: 1 } + } + if (sql.includes('SELECT COUNT(*)')) + return { rows: [{ count: 0 }], rowCount: 1 } + if (sql.includes('WITH prev AS')) + return { rows: [], rowCount: 1 } + return { rows: [], rowCount: 0 } + }) }) - it('handles 5000-file manifests with R2 before every DB delete and one final commit', async () => { - const entries = makeEntries(5000) - manifestSelectWhere.mockResolvedValue(entries) + it('handles 5000-file manifests with R2 before every DB delete', async () => { + manifestSelectWhere.mockResolvedValue(makeEntries(5000)) const response = await deleteIt(createContext(), createVersion({ r2_path: null, manifest_count: 5000 })) expect(response.status).toBe(200) expect(moveObjectToTrash).toHaveBeenCalledTimes(5000) - expect(manifestDeleteEq).toHaveBeenCalledTimes(5000) - expect(pgQuery).toHaveBeenCalledWith('COMMIT') - - const trashIndexes = callOrder.map((v, i) => v === 'r2_trash' ? i : -1).filter(i => i >= 0) - const deleteIndexes = callOrder.map((v, i) => v === 'db_delete_row' ? i : -1).filter(i => i >= 0) - expect(trashIndexes).toHaveLength(5000) - expect(deleteIndexes).toHaveLength(5000) - expect(deleteIndexes[0]).toBeGreaterThan(trashIndexes[0]) - expect(deleteIndexes.at(-1)!).toBeGreaterThan(trashIndexes[0]) - }, 30_000) + expect(callOrder.filter(v => v.startsWith('db_delete_row:'))).toHaveLength(5000) + expect(pgQuery).toHaveBeenCalledWith(expect.stringContaining('WITH prev AS'), expect.any(Array)) + }, 60_000) it('keeps remaining rows retryable when one file in a large batch fails trash', async () => { - const entries = makeEntries(200) - manifestSelectWhere.mockResolvedValue(entries) + manifestSelectWhere.mockResolvedValue(makeEntries(200)) moveObjectToTrash.mockImplementation(async (_c: unknown, path: string) => { callOrder.push('r2_trash') if (path.endsWith('file-150.js')) @@ -328,29 +351,7 @@ describe('on_version_update manifest cleanup load', () => { 'Cannot move S3 object for deleted manifest file to trash', ) - const deletedIds = manifestDeleteEq.mock.calls.map(call => call[1] as number) + const deletedIds = callOrder.filter(v => v.startsWith('db_delete_row:')).map(v => Number(v.split(':')[1])) expect(deletedIds).not.toContain(1150) - expect(pgQuery).not.toHaveBeenCalledWith('COMMIT') }, 30_000) - - it('does not finalize counters while rows remain after a partial failure', async () => { - manifestSelectWhere.mockResolvedValue(makeEntries(10)) - let deletes = 0 - manifestDeleteEq.mockImplementation((async () => { - deletes += 1 - callOrder.push('db_delete_row') - if (deletes >= 5) - return { error: { message: 'db down' } } - return { error: null } - }) as any) - moveObjectToTrash.mockImplementation(async () => { - callOrder.push('r2_trash') - return true - }) - - await expect(deleteIt(createContext(), createVersion({ r2_path: null, manifest_count: 10 }))).rejects.toThrow( - 'Cannot delete manifest row after R2 trash', - ) - expect(pgQuery).not.toHaveBeenCalledWith(expect.stringContaining('WITH prev AS')) - }) }) From 56dcb1f85e84ed48514296886b6800c45267c1de Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 22 Jul 2026 17:52:47 +0300 Subject: [PATCH 4/8] chore: stop tracking local supabase worktree artifacts Co-authored-by: Cursor --- .../supabase/.branches/_current_branch | 1 - .../c85d6572/supabase/.gitignore | 1 - .../c85d6572/supabase/.temp/cli-latest | 1 - .../c85d6572/supabase/config.toml | 442 ------------------ .../c85d6572/supabase/functions | 1 - .../c85d6572/supabase/migration_guide.md | 1 - .../c85d6572/supabase/migrations | 1 - .../c85d6572/supabase/schemas | 1 - .../c85d6572/supabase/seed.sql | 1 - .../c85d6572/supabase/templates | 1 - .../c85d6572/supabase/tests | 1 - .../supabase-worktrees/c85d6572/templates | 1 - .gitignore | 1 + 13 files changed, 1 insertion(+), 453 deletions(-) delete mode 100644 .context/supabase-worktrees/c85d6572/supabase/.branches/_current_branch delete mode 120000 .context/supabase-worktrees/c85d6572/supabase/.gitignore delete mode 100644 .context/supabase-worktrees/c85d6572/supabase/.temp/cli-latest delete mode 100644 .context/supabase-worktrees/c85d6572/supabase/config.toml delete mode 120000 .context/supabase-worktrees/c85d6572/supabase/functions delete mode 120000 .context/supabase-worktrees/c85d6572/supabase/migration_guide.md delete mode 120000 .context/supabase-worktrees/c85d6572/supabase/migrations delete mode 120000 .context/supabase-worktrees/c85d6572/supabase/schemas delete mode 120000 .context/supabase-worktrees/c85d6572/supabase/seed.sql delete mode 120000 .context/supabase-worktrees/c85d6572/supabase/templates delete mode 120000 .context/supabase-worktrees/c85d6572/supabase/tests delete mode 120000 .context/supabase-worktrees/c85d6572/templates diff --git a/.context/supabase-worktrees/c85d6572/supabase/.branches/_current_branch b/.context/supabase-worktrees/c85d6572/supabase/.branches/_current_branch deleted file mode 100644 index 88d050b190..0000000000 --- a/.context/supabase-worktrees/c85d6572/supabase/.branches/_current_branch +++ /dev/null @@ -1 +0,0 @@ -main \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/.gitignore b/.context/supabase-worktrees/c85d6572/supabase/.gitignore deleted file mode 120000 index 238aa65334..0000000000 --- a/.context/supabase-worktrees/c85d6572/supabase/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/Users/martindonadieu/Projects/capgo_all/capgo/supabase/.gitignore \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/.temp/cli-latest b/.context/supabase-worktrees/c85d6572/supabase/.temp/cli-latest deleted file mode 100644 index 8403edf467..0000000000 --- a/.context/supabase-worktrees/c85d6572/supabase/.temp/cli-latest +++ /dev/null @@ -1 +0,0 @@ -v2.109.1 \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/config.toml b/.context/supabase-worktrees/c85d6572/supabase/config.toml deleted file mode 100644 index 839ee33c5a..0000000000 --- a/.context/supabase-worktrees/c85d6572/supabase/config.toml +++ /dev/null @@ -1,442 +0,0 @@ -# For detailed configuration reference documentation, visit: -# https://supabase.com/docs/guides/local-development/cli/config -# A string used to distinguish different Supabase projects on the same host. Defaults to the -# working directory name when running `supabase init`. -project_id = "capgo-app-c85d6572" - -[api] -enabled = true -# Port to use for the API URL. -port = 55411 -# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API -# endpoints. `public` and `graphql_public` schemas are included by default. -schemas = ["public", "graphql_public"] -# Extra schemas to add to the search_path of every request. -extra_search_path = ["public", "extensions"] -# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size -# for accidental or malicious requests. -max_rows = 1000 - -[api.tls] -# Enable HTTPS endpoints locally using a self-signed certificate. -enabled = false - -[db] -# Port to use for the local database URL. -port = 55412 -# Port used by db diff command to initialize the shadow database. -shadow_port = 55410 -# The database major version to use. This has to be the same as your remote database's. Run `SHOW -# server_version;` on the remote database to check. -major_version = 17 - -[db.pooler] -enabled = false -# Port to use for the local connection pooler. -port = 55419 -# Specifies when a server connection can be reused by other clients. -# Configure one of the supported pooler modes: `transaction`, `session`. -pool_mode = "transaction" -# How many server connections to allow per user/database pair. -default_pool_size = 20 -# Maximum number of client connections allowed. -max_client_conn = 100 - -# [db.vault] -# secret_key = "env(SECRET_VALUE)" - -[db.seed] -# If enabled, seeds the database after migrations during a db reset. -enabled = true -# Specifies an ordered list of seed files to load during db reset. -# Supports glob patterns relative to supabase directory: "./seeds/*.sql" -sql_paths = ["./seed.sql"] - -[realtime] -enabled = false -# Bind realtime via either IPv4 or IPv6. (default: IPv4) -# ip_version = "IPv6" -# The maximum length in bytes of HTTP request headers. (default: 4096) -# max_header_length = 4096 - -[studio] -enabled = true -# Port to use for Supabase Studio. -port = 55413 -# External URL of the API server that frontend connects to. -api_url = "http://127.0.0.1" -# OpenAI API Key to use for Supabase AI in the Supabase Studio. -openai_api_key = "env(OPENAI_API_KEY)" - -# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they -# are monitored, and you can view the emails that would have been sent from the web interface. -[inbucket] -enabled = false -# Port to use for the email testing server web interface. -port = 55414 -# Uncomment to expose additional ports for testing user applications that send emails. -# smtp_port = 54325 -# pop3_port = 54326 -# admin_email = "admin@email.com" -# sender_name = "Admin" - -[storage] -enabled = true -# The maximum file size allowed (e.g. "5MB", "500KB"). -file_size_limit = "50MiB" - -[storage.s3_protocol] -enabled = true - -# Image transformation API is available to Supabase Pro plan. -# [storage.image_transformation] -# enabled = false - -# Uncomment to configure local storage buckets -# [storage.buckets.images] -# public = false -# file_size_limit = "50MiB" -# allowed_mime_types = ["image/png", "image/jpeg"] -# objects_path = "./images" - -[auth] -enabled = true -# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used -# in emails. -site_url = "http://127.0.0.1:3000" -# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://127.0.0.1:3000"] -# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). -jwt_expiry = 3600 -# If disabled, the refresh token will never expire. -enable_refresh_token_rotation = true -# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. -# Requires enable_refresh_token_rotation = true. -refresh_token_reuse_interval = 10 -# Allow/disallow new user signups to your project. -enable_signup = true -# Allow/disallow anonymous sign-ins to your project. -enable_anonymous_sign_ins = false -# Allow/disallow testing manual linking of accounts -enable_manual_linking = false -# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. -minimum_password_length = 6 -# Passwords that do not meet the following requirements will be rejected as weak. Supported values -# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` -password_requirements = "" - -# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. -# [auth.captcha] -# enabled = true -# provider = "hcaptcha" -# secret = "" - -[auth.email] -# Allow/disallow new user signups via email to your project. -enable_signup = true -# If enabled, a user will be required to confirm any email change on both the old, and new email -# addresses. If disabled, only the new email is required to confirm. -double_confirm_changes = true -# If enabled, users need to confirm their email address before signing in. -enable_confirmations = false -# If enabled, users will need to reauthenticate or have logged in recently to change their password. -secure_password_change = false -# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. -max_frequency = "1s" -# Number of characters used in the email OTP. -otp_length = 6 -# Number of seconds before the email OTP expires (defaults to 1 hour). -otp_expiry = 3600 - -# Use a production-ready SMTP server -# [auth.email.smtp] -# enabled = false -# host = "smtp.sendgrid.net" -# port = 587 -# user = "apikey" -# pass = "env(SENDGRID_API_KEY)" -# admin_email = "admin@email.com" -# sender_name = "Admin" - -[auth.email.template.confirmation] -subject = "[Capgo]: Confirm your Signup to Capgo.app" -content_path = "./templates/confirmation.html" - -[auth.email.template.invite] -subject = "[Capgo]: You have been invited to Capgo.app" -content_path = "./templates/invite.html" - -[auth.email.template.magic_link] -subject = "[Capgo]: Magic Link to log into your Capgo.app account" -content_path = "./templates/magic_link.html" - -[auth.email.template.email_change] -subject = "[Capgo]: Confirm change of your Email with Capgo.app account" -content_path = "./templates/email_change.html" - -[auth.email.template.recovery] -subject = "[Capgo]: Instructions to change your Capgo.app password" -content_path = "./templates/recovery.html" - -[auth.email.template.reauthentication] -subject = "[Capgo]: Confirm Reauthentication of your Capgo.app account" -content_path = "./templates/reauthentication.html" - -[auth.email.notification.password_changed] -enabled = true -subject = "[Capgo]: Your Capgo.app password has been changed" -content_path = "./templates/password_changed_notification.html" - -[auth.email.notification.email_changed] -enabled = true -subject = "[Capgo]: Your Capgo.app email address has been changed" -content_path = "./templates/email_changed_notification.html" - -[auth.email.notification.mfa_factor_enrolled] -enabled = true -subject = "[Capgo]: New multi-factor authentication method added to your Capgo.app account" -content_path = "./templates/mfa_factor_enrolled_notification.html" - -[auth.email.notification.mfa_factor_unenrolled] -enabled = true -subject = "[Capgo]: Multi-factor authentication method removed from your Capgo.app account" -content_path = "./templates/mfa_factor_unenrolled_notification.html" - -# Custom email templates kept in ./templates for consistency, but not managed by Supabase. -# These are sent through Bento: -# - invite_new_user_to_org.html -# subject = "[Capgo]: Join {{ event.details.org_name }} org & create account" -# - invite_existing_user_to_org.html -# subject = "[Capgo]: Join {{ event.details.org_name }} with your account" - -[auth.sms] -# Allow/disallow new user signups via SMS to your project. -enable_signup = false -# If enabled, users need to confirm their phone number before signing in. -enable_confirmations = false -# Template for sending OTP to users -template = "Your code is {{ .Code }}" -# Controls the minimum amount of time that must pass before sending another sms otp. -max_frequency = "5s" - -# Use pre-defined map of phone number to OTP for testing. -# [auth.sms.test_otp] -# 4152127777 = "123456" - -# Configure logged in session timeouts. -# [auth.sessions] -# Force log out after the specified duration. -# timebox = "24h" -# Force log out if the user has been inactive longer than the specified duration. -# inactivity_timeout = "8h" - -# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. -# [auth.hook.custom_access_token] -# enabled = false -# uri = "pg-functions:////" - -# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. -[auth.sms.twilio] -enabled = false -account_sid = "" -message_service_sid = "" -# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: -auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" - -# Multi-factor-authentication is available to Supabase Pro plan. -[auth.mfa] -# Control how many MFA factors can be enrolled at once per user. -max_enrolled_factors = 10 - -# Control MFA via App Authenticator (TOTP) -[auth.mfa.totp] -enroll_enabled = false -verify_enabled = false - -# Configure MFA via Phone Messaging -[auth.mfa.phone] -enroll_enabled = false -verify_enabled = false -otp_length = 6 -template = "Your code is {{ .Code }}" -max_frequency = "5s" - -# Configure MFA via WebAuthn -# [auth.mfa.web_authn] -# enroll_enabled = true -# verify_enabled = true - -# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, -# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, -# `twitter`, `slack`, `spotify`, `workos`, `zoom`. -[auth.external.apple] -enabled = false -client_id = "" -# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: -secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" -# Overrides the default auth redirectUrl. -redirect_uri = "" -# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, -# or any other third-party OIDC providers. -url = "" -# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. -skip_nonce_check = false - -# Use Firebase Auth as a third-party provider alongside Supabase Auth. -[auth.third_party.firebase] -enabled = false -# project_id = "my-firebase-project" - -# Use Auth0 as a third-party provider alongside Supabase Auth. -[auth.third_party.auth0] -enabled = false -# tenant = "my-auth0-tenant" -# tenant_region = "us" - -# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. -[auth.third_party.aws_cognito] -enabled = false -# user_pool_id = "my-user-pool-id" -# user_pool_region = "us-east-1" - -[edge_runtime] -deno_version = 1 -enabled = true -# Configure one of the supported request policies: `oneshot`, `per_worker`. -# Use `oneshot` for hot reload, or `per_worker` for load testing. -policy = "per_worker" -# Port to attach the Chrome inspector for debugging edge functions. -inspector_port = 8192 - -# Use these configurations to customize your Edge Function. -# [functions.MY_FUNCTION_NAME] -# enabled = true -# verify_jwt = true -# import_map = "./functions/MY_FUNCTION_NAME/deno.json" -# Uncomment to specify a custom file path to the entrypoint. -# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx -# entrypoint = "./functions/MY_FUNCTION_NAME/index.ts" -# Specifies static files to be bundled with the function. Supports glob patterns. -# For example, if you want to serve static HTML pages in your function: -# static_files = [ "./functions/MY_FUNCTION_NAME/*.html" ] - -[analytics] -enabled = false -port = 55417 -# Configure one of the supported backends: `postgres`, `bigquery`. -backend = "postgres" - -# Experimental features may be deprecated any time -[experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "" -# Configures S3 bucket URL, eg. .s3-.amazonaws.com -s3_host = "env(S3_HOST)" -# Configures S3 bucket region, eg. us-east-1 -s3_region = "env(S3_REGION)" -# Configures AWS_ACCESS_KEY_ID for S3 bucket -s3_access_key = "env(S3_ACCESS_KEY)" -# Configures AWS_SECRET_ACCESS_KEY for S3 bucket -s3_secret_key = "env(S3_SECRET_KEY)" - - -# PUBLIC ENDPOINTS - -[functions.ok] -verify_jwt = false -import_map = "./functions/deno.json" - -[functions.bundle] -verify_jwt = false -import_map = "./functions/deno.json" - -[functions.device] -verify_jwt = false -import_map = "./functions/deno.json" - -[functions.channel] -verify_jwt = false -import_map = "./functions/deno.json" - -[functions.organization] -verify_jwt = false -import_map = "./functions/deno.json" - -[functions.app] -verify_jwt = false -import_map = "./functions/deno.json" - -[functions.plugin_regions] -verify_jwt = false -import_map = "./functions/deno.json" - -[functions.build] -verify_jwt = false -import_map = "./functions/deno.json" - -[functions.notifications] -verify_jwt = false -import_map = "./functions/deno.json" - -# PLUGIN ENDPOINTS - -[functions.channel_self] -verify_jwt = false -import_map = "./functions/deno.json" - -[functions.updates] -verify_jwt = false -import_map = "./functions/deno.json" - -[functions.updates_debug] -verify_jwt = false -import_map = "./functions/deno.json" - -[functions.stats] -verify_jwt = false -import_map = "./functions/deno.json" - -# PRIVATE ENDPOINTS - -[functions.private] -verify_jwt = false -import_map = "./functions/deno.json" - -# STATISTICS ENDPOINTS -[functions.statistics] -verify_jwt = false -import_map = "./functions/deno.json" - -# TRIGGERS ENDPOINTS - -[functions.triggers] -verify_jwt = false -import_map = "./functions/deno.json" - -[functions.apikey] -verify_jwt = false -import_map = "./functions/deno.json" - -# FILES ENDPOINTS - -[functions.files] -verify_jwt = false -import_map = "./functions/deno.json" - -# WEBHOOKS ENDPOINTS - -[functions.webhooks] -verify_jwt = false -import_map = "./functions/deno.json" - -# Replication - -[functions.replication] -verify_jwt = false -import_map = "./functions/deno.json" - -# Check CPU Usage - -[functions.check_cpu_usage] -verify_jwt = false -import_map = "./functions/deno.json" diff --git a/.context/supabase-worktrees/c85d6572/supabase/functions b/.context/supabase-worktrees/c85d6572/supabase/functions deleted file mode 120000 index 0afbcbbe17..0000000000 --- a/.context/supabase-worktrees/c85d6572/supabase/functions +++ /dev/null @@ -1 +0,0 @@ -/Users/martindonadieu/Projects/capgo_all/capgo/supabase/functions \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/migration_guide.md b/.context/supabase-worktrees/c85d6572/supabase/migration_guide.md deleted file mode 120000 index dc745ce9b7..0000000000 --- a/.context/supabase-worktrees/c85d6572/supabase/migration_guide.md +++ /dev/null @@ -1 +0,0 @@ -/Users/martindonadieu/Projects/capgo_all/capgo/supabase/migration_guide.md \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/migrations b/.context/supabase-worktrees/c85d6572/supabase/migrations deleted file mode 120000 index 9f36c17e78..0000000000 --- a/.context/supabase-worktrees/c85d6572/supabase/migrations +++ /dev/null @@ -1 +0,0 @@ -/Users/martindonadieu/Projects/capgo_all/capgo/supabase/migrations \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/schemas b/.context/supabase-worktrees/c85d6572/supabase/schemas deleted file mode 120000 index 35e4c98d83..0000000000 --- a/.context/supabase-worktrees/c85d6572/supabase/schemas +++ /dev/null @@ -1 +0,0 @@ -/Users/martindonadieu/Projects/capgo_all/capgo/supabase/schemas \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/seed.sql b/.context/supabase-worktrees/c85d6572/supabase/seed.sql deleted file mode 120000 index 3f9f6f4f39..0000000000 --- a/.context/supabase-worktrees/c85d6572/supabase/seed.sql +++ /dev/null @@ -1 +0,0 @@ -/Users/martindonadieu/Projects/capgo_all/capgo/supabase/seed.sql \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/templates b/.context/supabase-worktrees/c85d6572/supabase/templates deleted file mode 120000 index d86886ea2b..0000000000 --- a/.context/supabase-worktrees/c85d6572/supabase/templates +++ /dev/null @@ -1 +0,0 @@ -/Users/martindonadieu/Projects/capgo_all/capgo/supabase/templates \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/supabase/tests b/.context/supabase-worktrees/c85d6572/supabase/tests deleted file mode 120000 index c6ef97f3c4..0000000000 --- a/.context/supabase-worktrees/c85d6572/supabase/tests +++ /dev/null @@ -1 +0,0 @@ -/Users/martindonadieu/Projects/capgo_all/capgo/supabase/tests \ No newline at end of file diff --git a/.context/supabase-worktrees/c85d6572/templates b/.context/supabase-worktrees/c85d6572/templates deleted file mode 120000 index d86886ea2b..0000000000 --- a/.context/supabase-worktrees/c85d6572/templates +++ /dev/null @@ -1 +0,0 @@ -/Users/martindonadieu/Projects/capgo_all/capgo/supabase/templates \ No newline at end of file diff --git a/.gitignore b/.gitignore index 02aa7473a5..95ea5a0360 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,4 @@ cli-helper/dist/ cli-helper/npm/*/CapgoKeychainHelper.app cli-helper/npm/*/CapgoAscKeyHelper.app .tinbase/ +.context/ From 48e5bc19b96dd7beddd6d9d0bdd3121b2bb2227b Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 22 Jul 2026 18:03:28 +0300 Subject: [PATCH 5/8] fix(db): repair broken comment in manifest sweeper migration Co-authored-by: Cursor --- .../20260722095157_sweep_deleted_version_manifests.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/supabase/migrations/20260722095157_sweep_deleted_version_manifests.sql b/supabase/migrations/20260722095157_sweep_deleted_version_manifests.sql index 4916bdde51..69119503e7 100644 --- a/supabase/migrations/20260722095157_sweep_deleted_version_manifests.sql +++ b/supabase/migrations/20260722095157_sweep_deleted_version_manifests.sql @@ -7,7 +7,8 @@ CREATE INDEX IF NOT EXISTS idx_app_versions_deleted_with_manifest CREATE INDEX IF NOT EXISTS idx_app_versions_deleted_at_id ON public.app_versions (deleted_at, id) WHERE deleted = true; - that still have manifest rows or stale counters. + +-- Sweeps soft-deleted app_versions that still have manifest rows or stale counters. -- Touches a bounded batch so on_version_update re-runs cleanup_manifest. -- Also zeros stale manifest_count when no rows remain. From 833eebe06c9a51f70fde45fd2a747c522fd4bffa Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 22 Jul 2026 18:09:21 +0300 Subject: [PATCH 6/8] fix(storage): timeout HEAD probe in getObjectPresence Keep deleted-manifest cleanup fail-closed when R2 HEAD hangs. Co-authored-by: Cursor --- supabase/functions/_backend/utils/s3.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/supabase/functions/_backend/utils/s3.ts b/supabase/functions/_backend/utils/s3.ts index ada7e37b31..e2d966f853 100644 --- a/supabase/functions/_backend/utils/s3.ts +++ b/supabase/functions/_backend/utils/s3.ts @@ -150,7 +150,10 @@ async function getObjectPresence(c: Context, fileId: string | null): Promise Date: Wed, 22 Jul 2026 18:10:12 +0300 Subject: [PATCH 7/8] fix(queue): align Discord alerts with per-queue retry budget Also URL-encode reclaim CopySource so trash moves don't fail on special key chars. Co-authored-by: Cursor --- scripts/reclaim_deleted_version_manifests.ts | 3 ++- .../_backend/triggers/queue_consumer.ts | 11 ++++---- .../queue-consumer-message-shape.unit.test.ts | 25 +++++++++++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/scripts/reclaim_deleted_version_manifests.ts b/scripts/reclaim_deleted_version_manifests.ts index ba16250369..682b3e5991 100644 --- a/scripts/reclaim_deleted_version_manifests.ts +++ b/scripts/reclaim_deleted_version_manifests.ts @@ -84,9 +84,10 @@ async function moveToTrash(s3: S3Client, bucket: string, key: string) { if (!exists) return + const encodedKey = key.split('/').map(segment => encodeURIComponent(segment)).join('/') await s3.send(new CopyObjectCommand({ Bucket: bucket, - CopySource: `${bucket}/${key}`, + CopySource: `${bucket}/${encodedKey}`, Key: `${TRASH_PREFIX}${key}`, })) await s3.send(new DeleteObjectCommand({ diff --git a/supabase/functions/_backend/triggers/queue_consumer.ts b/supabase/functions/_backend/triggers/queue_consumer.ts index b6f2640c49..4f5c9c5633 100644 --- a/supabase/functions/_backend/triggers/queue_consumer.ts +++ b/supabase/functions/_backend/triggers/queue_consumer.ts @@ -138,9 +138,9 @@ function getQueueMessageTrace(functionName: string, body: Record { - if (detail.read_count < MAX_QUEUE_READS) + if (detail.read_count < retryBudget) return false return !detail.error_code || !DISCORD_IGNORED_ERROR_CODES.has(detail.error_code) }) @@ -682,6 +682,7 @@ async function reportQueueFailures(c: Context, queueName: string, messagesFailed cloudlog({ requestId: c.get('requestId'), message: `[${queueName}] Failed to process ${messagesFailed.length} messages.` }) + const retryBudget = getQueueMaxReads(queueName) const timestamp = new Date().toISOString() const failureDetails = messagesFailed.map(msg => ({ function_name: msg.message?.function_name ?? 'unknown', @@ -699,7 +700,7 @@ async function reportQueueFailures(c: Context, queueName: string, messagesFailed target_url: msg.targetUrl ?? undefined, })) - const actionableFailures = getActionableQueueFailures(failureDetails) + const actionableFailures = getActionableQueueFailures(failureDetails, retryBudget) const groupedByFunction = actionableFailures.reduce((acc, detail) => { const key = detail.function_name acc[key] ??= [] @@ -734,7 +735,7 @@ async function reportQueueFailures(c: Context, queueName: string, messagesFailed const messageInfo = detail.error_message ? ` | ${truncateDiscordField(detail.error_message.replace(/\s+/g, ' ').trim(), 180)}` : '' const durationInfo = typeof detail.duration_ms === 'number' ? ` | ${detail.duration_ms}ms` : '' const targetInfo = detail.target_url ? ` | Target: ${truncateDiscordField(detail.target_url, 120)}` : '' - return `**${detail.function_name}** | Status: ${detail.status} | Read: ${detail.read_count}/${MAX_QUEUE_READS}${durationInfo}${errorInfo}${messageInfo}${targetInfo} | [CF Logs](${cfLogUrl})` + return `**${detail.function_name}** | Status: ${detail.status} | Read: ${detail.read_count}/${retryBudget}${durationInfo}${errorInfo}${messageInfo}${targetInfo} | [CF Logs](${cfLogUrl})` }).join('\n')), inline: false, }, @@ -777,7 +778,7 @@ async function reportQueueFailures(c: Context, queueName: string, messagesFailed cloudlog({ requestId: c.get('requestId'), message: `[${queueName}] Suppressed Discord alert for retryable or ignored queue failures.`, - retryingFailures: failureDetails.filter(detail => detail.read_count < MAX_QUEUE_READS).length, + retryingFailures: failureDetails.filter(detail => detail.read_count < retryBudget).length, ignoredErrors: Array.from(DISCORD_IGNORED_ERROR_CODES), }) } diff --git a/tests/queue-consumer-message-shape.unit.test.ts b/tests/queue-consumer-message-shape.unit.test.ts index 1ef24325dd..352bd09135 100644 --- a/tests/queue-consumer-message-shape.unit.test.ts +++ b/tests/queue-consumer-message-shape.unit.test.ts @@ -237,6 +237,31 @@ describe('queue_consumer legacy message compatibility', () => { )).toBe('continue') }) + it.concurrent('uses the version queue retry budget for Discord failure alerts', () => { + const versionRetryBudget = __queueConsumerTestUtils__.getQueueMaxReads('on_version_update') + const midRetry = { + cf_id: 'cf-version-mid', + error_code: 'manifest_cleanup_incomplete', + function_name: 'on_version_update', + function_type: 'supabase', + msg_id: 2, + payload_size: 10, + read_count: MAX_QUEUE_READS, + status: 500, + status_text: 'Internal Server Error', + } + const exhausted = { + ...midRetry, + cf_id: 'cf-version-done', + msg_id: 3, + read_count: versionRetryBudget, + } + + expect(versionRetryBudget).toBe(30) + expect(__queueConsumerTestUtils__.getActionableQueueFailures([midRetry], versionRetryBudget)).toEqual([]) + expect(__queueConsumerTestUtils__.getActionableQueueFailures([exhausted], versionRetryBudget)).toEqual([exhausted]) + }) + it.concurrent('alerts Discord after retry budget is exhausted', () => { const failure = { cf_id: 'cf-1', From 80344bf9ac0dcbe1b1670bf47b104e1457a8411d Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 22 Jul 2026 18:17:18 +0300 Subject: [PATCH 8/8] chore(replica): refresh schema snapshot for deleted-version indexes Co-authored-by: Cursor --- read_replicate/schema_replicate.catalog.json | 14 ++++++++++++++ read_replicate/schema_replicate.sql | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/read_replicate/schema_replicate.catalog.json b/read_replicate/schema_replicate.catalog.json index 4857f5c5bb..f7b40ad9f5 100644 --- a/read_replicate/schema_replicate.catalog.json +++ b/read_replicate/schema_replicate.catalog.json @@ -2052,6 +2052,20 @@ "table": "app_versions", "valid": true }, + { + "constraintOwned": false, + "definition": "CREATE INDEX idx_app_versions_deleted_at_id ON public.app_versions USING btree (deleted_at, id) WHERE (deleted = true)", + "name": "idx_app_versions_deleted_at_id", + "table": "app_versions", + "valid": true + }, + { + "constraintOwned": false, + "definition": "CREATE INDEX idx_app_versions_deleted_with_manifest ON public.app_versions USING btree (id) WHERE ((deleted = true) AND (manifest_count > 0))", + "name": "idx_app_versions_deleted_with_manifest", + "table": "app_versions", + "valid": true + }, { "constraintOwned": false, "definition": "CREATE INDEX idx_app_versions_id ON public.app_versions USING btree (id)", diff --git a/read_replicate/schema_replicate.sql b/read_replicate/schema_replicate.sql index 94e69f1dc1..ec28434c82 100644 --- a/read_replicate/schema_replicate.sql +++ b/read_replicate/schema_replicate.sql @@ -768,6 +768,20 @@ CREATE INDEX idx_app_versions_deleted ON public.app_versions USING btree (delete CREATE INDEX idx_app_versions_deleted_at ON public.app_versions USING btree (deleted_at) WHERE (deleted_at IS NOT NULL); +-- +-- Name: idx_app_versions_deleted_at_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_app_versions_deleted_at_id ON public.app_versions USING btree (deleted_at, id) WHERE (deleted = true); + + +-- +-- Name: idx_app_versions_deleted_with_manifest; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_app_versions_deleted_with_manifest ON public.app_versions USING btree (id) WHERE ((deleted = true) AND (manifest_count > 0)); + + -- -- Name: idx_app_versions_id; Type: INDEX; Schema: public; Owner: - --