diff --git a/packages/sync-engine/src/supabase/supabase.test.ts b/packages/sync-engine/src/supabase/supabase.test.ts index 12a74bfb..4a9b507c 100644 --- a/packages/sync-engine/src/supabase/supabase.test.ts +++ b/packages/sync-engine/src/supabase/supabase.test.ts @@ -474,6 +474,57 @@ describe('SupabaseDeployClient', () => { } }) + it('should return false when uninstallation is in progress (uninstallation:started)', async () => { + const client = new SupabaseSetupClient({ + accessToken: mockAccessToken, + projectRef: mockProjectRef, + }) + + // Mock runSQL to return in-progress uninstallation + const mockRunSQL = vi + .fn() + .mockResolvedValueOnce([{ rows: [{ schema_exists: true }] }]) // schema exists + .mockResolvedValueOnce([{ rows: [{ table_exists: true }] }]) // migrations table exists + .mockResolvedValueOnce([ + { rows: [{ comment: 'stripe-sync v1.0.0 uninstallation:started' }] }, + ]) // in progress + // @ts-expect-error - accessing private method for testing + client.runSQL = mockRunSQL + + const installed = await client.isInstalled() + + expect(installed).toBe(false) + }) + + it('should throw error when uninstallation has failed (uninstallation:error)', async () => { + const client = new SupabaseSetupClient({ + accessToken: mockAccessToken, + projectRef: mockProjectRef, + }) + + // Mock runSQL to return failed uninstallation + const mockRunSQL = vi + .fn() + .mockResolvedValueOnce([{ rows: [{ schema_exists: true }] }]) // schema exists + .mockResolvedValueOnce([{ rows: [{ table_exists: true }] }]) // migrations table exists + .mockResolvedValueOnce([ + { + rows: [{ comment: 'stripe-sync v1.0.0 uninstallation:error - Cleanup failed' }], + }, + ]) // failed + // @ts-expect-error - accessing private method for testing + client.runSQL = mockRunSQL + + try { + await client.isInstalled() + expect.fail('Should have thrown an error') + } catch (error) { + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain('Uninstallation failed') + expect((error as Error).message).toContain('Manual cleanup may be required') + } + }) + it('should return true when installation is complete', async () => { const client = new SupabaseSetupClient({ accessToken: mockAccessToken, diff --git a/packages/sync-engine/src/supabase/supabase.ts b/packages/sync-engine/src/supabase/supabase.ts index 71595c2b..fac924af 100644 --- a/packages/sync-engine/src/supabase/supabase.ts +++ b/packages/sync-engine/src/supabase/supabase.ts @@ -11,6 +11,8 @@ export const STRIPE_SCHEMA_COMMENT_PREFIX = 'stripe-sync' export const INSTALLATION_STARTED_SUFFIX = 'installation:started' export const INSTALLATION_ERROR_SUFFIX = 'installation:error' export const INSTALLATION_INSTALLED_SUFFIX = 'installed' +export const UNINSTALLATION_STARTED_SUFFIX = 'uninstallation:started' +export const UNINSTALLATION_ERROR_SUFFIX = 'uninstallation:error' export interface DeployClientOptions { accessToken: string @@ -310,6 +312,27 @@ export class SupabaseSetupClient { return { success: true } } + /** + * Check if a schema exists in the database + * @param schema The schema name to check (defaults to 'stripe') + * @returns true if schema exists, false otherwise + */ + private async schemaExists(schema = 'stripe'): Promise { + try { + const schemaCheck = (await this.runSQL( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.schemata + WHERE schema_name = '${schema}' + ) as schema_exists` + )) as { rows?: { schema_exists: boolean }[] }[] + + return schemaCheck[0]?.rows?.[0]?.schema_exists === true + } catch { + // Return false if query fails + return false + } + } + /** * Check if stripe-sync is installed in the database. * @@ -324,16 +347,9 @@ export class SupabaseSetupClient { async isInstalled(schema = 'stripe'): Promise { try { // Step 1: Duck typing - check if schema exists - const schemaCheck = (await this.runSQL( - `SELECT EXISTS ( - SELECT 1 FROM information_schema.schemata - WHERE schema_name = '${schema}' - ) as schema_exists` - )) as { rows?: { schema_exists: boolean }[] }[] + const schemaExistsResult = await this.schemaExists(schema) - const schemaExists = schemaCheck[0]?.rows?.[0]?.schema_exists === true - - if (!schemaExists) { + if (!schemaExistsResult) { // Schema doesn't exist - not installed return false } @@ -371,6 +387,20 @@ export class SupabaseSetupClient { ) } + // Check for uninstallation in progress + // NOTE: Check uninstallation before installation since "uninstallation:*" contains "installation:*" + if (comment.includes(UNINSTALLATION_STARTED_SUFFIX)) { + return false + } + + // Check for failed uninstallation (requires manual intervention) + if (comment.includes(UNINSTALLATION_ERROR_SUFFIX)) { + throw new Error( + `Uninstallation failed: Schema '${schema}' exists but uninstallation encountered an error. ` + + `Comment: ${comment}. Manual cleanup may be required.` + ) + } + // Check for incomplete installation (can retry) if (comment.includes(INSTALLATION_STARTED_SUFFIX)) { return false @@ -391,7 +421,8 @@ export class SupabaseSetupClient { if ( error instanceof Error && (error.message.includes('Legacy installation detected') || - error.message.includes('Installation failed')) + error.message.includes('Installation failed') || + error.message.includes('Uninstallation failed')) ) { throw error } @@ -435,9 +466,18 @@ export class SupabaseSetupClient { /** * Uninstall stripe-sync from a Supabase project * Invokes the stripe-setup edge function's DELETE endpoint which handles cleanup + * Tracks uninstallation progress via schema comments */ async uninstall(): Promise { try { + // Check if schema exists and mark uninstall as started + const hasSchema = await this.schemaExists('stripe') + if (hasSchema) { + await this.updateInstallationComment( + `${STRIPE_SCHEMA_COMMENT_PREFIX} v${pkg.version} ${UNINSTALLATION_STARTED_SUFFIX}` + ) + } + // Invoke the DELETE endpoint on stripe-setup function // Use accessToken in Authorization header for Management API validation const url = `https://${this.projectRef}.${this.projectBaseUrl}/functions/v1/stripe-setup` @@ -458,7 +498,23 @@ export class SupabaseSetupClient { if (result.success === false) { throw new Error(`Uninstall failed: ${result.error}`) } + // On success, schema is dropped by edge function (no comment update needed) } catch (error) { + // Mark schema with error if it still exists + try { + const hasSchema = await this.schemaExists('stripe') + if (hasSchema) { + await this.updateInstallationComment( + `${STRIPE_SCHEMA_COMMENT_PREFIX} v${pkg.version} ${UNINSTALLATION_ERROR_SUFFIX} - ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + } catch (error) { + throw new Error( + `Uninstall failed: ${error instanceof Error ? error.message : String(error)}` + ) + } throw new Error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`) } }