fix(storage): purge raw audio when its history entry or meeting is deleted
This commit is contained in:
parent
4807a5283d
commit
ed790e672b
10 changed files with 1073 additions and 39 deletions
67
server/supabase/functions/_shared/storage-objects.test.ts
Normal file
67
server/supabase/functions/_shared/storage-objects.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import {
|
||||
listStorageFiles,
|
||||
removeStorageObjects,
|
||||
STORAGE_BATCH_SIZE,
|
||||
type StorageBucketPort,
|
||||
type StorageEntry,
|
||||
} from './storage-objects.ts'
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown, message: string): void {
|
||||
const a = JSON.stringify(actual)
|
||||
const e = JSON.stringify(expected)
|
||||
if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`)
|
||||
}
|
||||
|
||||
class FakeBucket implements StorageBucketPort {
|
||||
readonly bucket = 'audio'
|
||||
listCalls: Array<{ prefix: string; offset: number }> = []
|
||||
removed: string[][] = []
|
||||
constructor(private tree: Record<string, StorageEntry[]>, private failRemoveAt = -1) {}
|
||||
list(prefix: string, offset: number, limit: number): Promise<StorageEntry[]> {
|
||||
this.listCalls.push({ prefix, offset })
|
||||
return Promise.resolve((this.tree[prefix] ?? []).slice(offset, offset + limit))
|
||||
}
|
||||
remove(paths: string[]): Promise<void> {
|
||||
if (this.removed.length === this.failRemoveAt) return Promise.reject(new Error('storage_delete_failed:audio'))
|
||||
this.removed.push(paths)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test('storage-objects: listStorageFiles walks folders and pages', async () => {
|
||||
const many = Array.from({ length: STORAGE_BATCH_SIZE + 3 }, (_, i) => ({ name: `f${i}.wav`, id: `id${i}` }))
|
||||
const bucket = new FakeBucket({
|
||||
u: [{ name: 'history', id: null }, { name: 'top.wav', id: 'top' }],
|
||||
'u/history': many,
|
||||
})
|
||||
const files = await listStorageFiles(bucket, 'u')
|
||||
|
||||
assertEquals(files.length, STORAGE_BATCH_SIZE + 4, 'all files found')
|
||||
assertEquals(files[files.length - 1], 'u/top.wav', 'depth-first order')
|
||||
assertEquals(
|
||||
bucket.listCalls,
|
||||
[{ prefix: 'u', offset: 0 }, { prefix: 'u/history', offset: 0 }, { prefix: 'u/history', offset: STORAGE_BATCH_SIZE }],
|
||||
'paged listing',
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('storage-objects: removeStorageObjects batches and stops on the first failure', async () => {
|
||||
const paths = Array.from({ length: STORAGE_BATCH_SIZE * 2 + 1 }, (_, i) => `u/${i}`)
|
||||
const ok = new FakeBucket({})
|
||||
await removeStorageObjects(ok, paths)
|
||||
assertEquals(ok.removed.map((b) => b.length), [STORAGE_BATCH_SIZE, STORAGE_BATCH_SIZE, 1], 'batch sizes')
|
||||
|
||||
const failing = new FakeBucket({}, 1)
|
||||
let message = ''
|
||||
try {
|
||||
await removeStorageObjects(failing, paths)
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : ''
|
||||
}
|
||||
assertEquals(message, 'storage_delete_failed:audio', 'error propagates')
|
||||
assertEquals(failing.removed.length, 1, 'later batches are not attempted')
|
||||
|
||||
const empty = new FakeBucket({})
|
||||
await removeStorageObjects(empty, [])
|
||||
assertEquals(empty.removed.length, 0, 'nothing to remove')
|
||||
})
|
||||
82
server/supabase/functions/_shared/storage-objects.ts
Normal file
82
server/supabase/functions/_shared/storage-objects.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// 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<StorageEntry[]>
|
||||
remove(paths: string[]): Promise<void>
|
||||
}
|
||||
|
||||
/** Every object path under prefix, depth-first, in name order. */
|
||||
export async function listStorageFiles(storage: StorageBucketPort, prefix: string): Promise<string[]> {
|
||||
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<StorageBucketPort, 'remove'>,
|
||||
paths: readonly string[],
|
||||
): Promise<void> {
|
||||
for (let index = 0; index < paths.length; index += STORAGE_BATCH_SIZE) {
|
||||
await storage.remove(paths.slice(index, index + STORAGE_BATCH_SIZE))
|
||||
}
|
||||
}
|
||||
|
||||
type StorageClient = Pick<SupabaseClient, 'storage'>
|
||||
|
||||
/** 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}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue