// server/supabase/functions/_shared/storage-objects.ts // Storage bucket port + the listing/removal routines shared by account-delete // and audio-purge. Callers depend on StorageBucketPort, not on supabase-js, so // the routines are unit-testable with an in-memory fake. import type { SupabaseClient } from '@supabase/supabase-js' /** Storage API page and batch size (remove() accepts at most 100 paths well within limits). */ export const STORAGE_BATCH_SIZE = 100 export interface StorageEntry { name: string /** null for a folder placeholder, the object id for a file. */ id: string | null } export interface StorageBucketPort { readonly bucket: string list(prefix: string, offset: number, limit: number): Promise remove(paths: string[]): Promise } /** Every object path under prefix, depth-first, in name order. */ export async function listStorageFiles(storage: StorageBucketPort, prefix: string): Promise { const result: string[] = [] let offset = 0 while (true) { const page = await storage.list(prefix, offset, STORAGE_BATCH_SIZE) if (page.length === 0) break for (const entry of page) { const childPath = `${prefix}/${entry.name}` if (entry.id) { result.push(childPath) } else { result.push(...await listStorageFiles(storage, childPath)) } } if (page.length < STORAGE_BATCH_SIZE) break offset += page.length } return result } /** Remove paths in API-sized batches; the first failing batch aborts the rest. */ export async function removeStorageObjects( storage: Pick, paths: readonly string[], ): Promise { for (let index = 0; index < paths.length; index += STORAGE_BATCH_SIZE) { await storage.remove(paths.slice(index, index + STORAGE_BATCH_SIZE)) } } type StorageClient = Pick /** supabase-js adapter. Errors keep the storage_list_failed / storage_delete_failed codes. */ export function supabaseStorageBucket(client: StorageClient, bucket: string): StorageBucketPort { return { bucket, async list(prefix, offset, limit) { const { data, error } = await client.storage.from(bucket).list(prefix, { limit, offset, sortBy: { column: 'name', order: 'asc' }, }) if (error) throw new Error(`storage_list_failed:${bucket}`) return (data ?? []).map((entry) => ({ name: entry.name, id: typeof entry.id === 'string' && entry.id.length > 0 ? entry.id : null, })) }, async remove(paths) { if (paths.length === 0) return const { error } = await client.storage.from(bucket).remove(paths) if (error) throw new Error(`storage_delete_failed:${bucket}`) }, } }