fix(campaigns): delete universes in dependency order
Some checks failed
CI / validate (push) Has been cancelled
Some checks failed
CI / validate (push) Has been cancelled
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { stageTwoDatabase } from './stage-two-supabase'
|
||||
import { deleteStageTwoCampaign, stageTwoDatabase } from './stage-two-supabase'
|
||||
|
||||
describe('stage-two Supabase client', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
@@ -48,4 +48,64 @@ describe('stage-two Supabase client', () => {
|
||||
Authorization: 'Bearer header.payload.signature',
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a campaign before deleting its orphaned world', async () => {
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
supabaseUrl: 'https://example.supabase.co',
|
||||
supabaseServiceRoleKey: 'service-role-key',
|
||||
public: { supabaseAnonKey: 'anon-key' },
|
||||
}))
|
||||
const fetchMock = vi.fn(async (url: URL, init?: RequestInit) => {
|
||||
if (url.pathname === '/rest/v1/ai_usage') return new Response(null, { status: 204 })
|
||||
if (url.pathname === '/rest/v1/campaigns' && init?.method === 'DELETE') {
|
||||
return Response.json([{ id: '11111111-1111-4111-8111-111111111111' }])
|
||||
}
|
||||
if (url.pathname === '/rest/v1/campaigns') return Response.json([])
|
||||
if (url.pathname === '/rest/v1/worlds') return new Response(null, { status: 204 })
|
||||
return new Response(null, { status: 404 })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await deleteStageTwoCampaign(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'22222222-2222-4222-8222-222222222222',
|
||||
)
|
||||
|
||||
expect(fetchMock.mock.calls.map(([url, init]) => [
|
||||
(url as URL).pathname,
|
||||
(url as URL).search,
|
||||
init?.method ?? 'GET',
|
||||
])).toEqual([
|
||||
['/rest/v1/ai_usage', '?campaign_id=eq.11111111-1111-4111-8111-111111111111', 'PATCH'],
|
||||
['/rest/v1/campaigns', '?select=id&id=eq.11111111-1111-4111-8111-111111111111', 'DELETE'],
|
||||
['/rest/v1/campaigns', '?select=id&world_id=eq.22222222-2222-4222-8222-222222222222&limit=1', 'GET'],
|
||||
['/rest/v1/worlds', '?id=eq.22222222-2222-4222-8222-222222222222', 'DELETE'],
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a world that is still used by another campaign', async () => {
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
supabaseUrl: 'https://example.supabase.co',
|
||||
supabaseServiceRoleKey: 'service-role-key',
|
||||
public: { supabaseAnonKey: 'anon-key' },
|
||||
}))
|
||||
const fetchMock = vi.fn(async (url: URL, init?: RequestInit) => {
|
||||
if (url.pathname === '/rest/v1/ai_usage') return new Response(null, { status: 204 })
|
||||
if (url.pathname === '/rest/v1/campaigns' && init?.method === 'DELETE') {
|
||||
return Response.json([{ id: '11111111-1111-4111-8111-111111111111' }])
|
||||
}
|
||||
if (url.pathname === '/rest/v1/campaigns') {
|
||||
return Response.json([{ id: '33333333-3333-4333-8333-333333333333' }])
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await deleteStageTwoCampaign(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'22222222-2222-4222-8222-222222222222',
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,6 +123,32 @@ export async function requireCampaignAccess(campaignId: string, userId: string,
|
||||
return { campaign, owner: false, memberId: members[0].id }
|
||||
}
|
||||
|
||||
export async function deleteStageTwoCampaign(campaignId: string, worldId: string): Promise<void> {
|
||||
// Usage is retained for account history, but its restrictive foreign key
|
||||
// must no longer point at the campaign being removed.
|
||||
await stageTwoDatabase(`ai_usage?campaign_id=eq.${campaignId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ campaign_id: null }),
|
||||
})
|
||||
|
||||
// Campaign-owned rows use ON DELETE CASCADE. Deleting the parent lets
|
||||
// Postgres remove them in dependency-safe order in a single statement.
|
||||
const deleted = await stageTwoDatabase<Array<{ id: string }>>(
|
||||
`campaigns?select=id&id=eq.${campaignId}`,
|
||||
{ method: 'DELETE', prefer: 'return=representation' },
|
||||
)
|
||||
if (!deleted[0]) throw createError({ statusCode: 404, statusMessage: 'Campaign not found.' })
|
||||
|
||||
const otherCampaigns = await stageTwoDatabase<Array<{ id: string }>>(
|
||||
`campaigns?select=id&world_id=eq.${worldId}&limit=1`,
|
||||
)
|
||||
if (!otherCampaigns[0]) {
|
||||
// World entities cascade from the world; confirmed coauthor sessions are
|
||||
// retained and automatically clear their world reference.
|
||||
await stageTwoDatabase(`worlds?id=eq.${worldId}`, { method: 'DELETE' })
|
||||
}
|
||||
}
|
||||
|
||||
export function stageTwoApiError(error: unknown): never {
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error
|
||||
if (error instanceof StageTwoDatabaseError) {
|
||||
|
||||
Reference in New Issue
Block a user