Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/sync-engine/src/supabase/supabase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 66 additions & 10 deletions packages/sync-engine/src/supabase/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<boolean> {
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.
*
Expand All @@ -324,16 +347,9 @@ export class SupabaseSetupClient {
async isInstalled(schema = 'stripe'): Promise<boolean> {
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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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<void> {
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`
Expand All @@ -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)}`)
}
}
Expand Down
Loading