sandbox.tsx143 lines · main
| 1 | import { noop } from 'lodash' |
| 2 | import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' |
| 3 | import { toast } from 'sonner' |
| 4 | |
| 5 | import { AUTH_USERS_SEED_TABLE } from './sandbox.constants' |
| 6 | import { getSandboxCore, type SandboxCore } from './sandbox.core' |
| 7 | import { getDatabaseSchemaDDL } from '@/data/rls-tester/get-schema-ddl' |
| 8 | import { getProjectSeedData, TableSeedData } from '@/data/rls-tester/get-seed-data' |
| 9 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 10 | import { getErrorMessage } from '@/lib/get-error-message' |
| 11 | |
| 12 | type SandboxStatus = 'idle' | 'loading' | 'ready' | 'error' |
| 13 | |
| 14 | const SandboxContext = createContext<{ |
| 15 | status: SandboxStatus |
| 16 | error?: string |
| 17 | sandbox: SandboxCore | null |
| 18 | isSyncing: boolean |
| 19 | startSandbox: () => void |
| 20 | destroySandbox: () => Promise<void> |
| 21 | syncSandbox: () => Promise<void> |
| 22 | }>({ |
| 23 | status: 'idle', |
| 24 | error: undefined, |
| 25 | sandbox: null, |
| 26 | isSyncing: false, |
| 27 | startSandbox: noop, |
| 28 | destroySandbox: async () => {}, |
| 29 | syncSandbox: async () => {}, |
| 30 | }) |
| 31 | |
| 32 | export const PostgresSandboxProvider = ({ children }: PropsWithChildren) => { |
| 33 | const { data: project } = useSelectedProjectQuery() |
| 34 | |
| 35 | const [start, setStart] = useState<boolean>(false) |
| 36 | const [error, setError] = useState<string>() |
| 37 | const [sandbox, setSandbox] = useState<SandboxCore | null>(null) |
| 38 | |
| 39 | const [status, setStatus] = useState<SandboxStatus>('idle') |
| 40 | const [isSyncing, setIsSyncing] = useState(false) |
| 41 | |
| 42 | const destroySandbox = async () => { |
| 43 | if (isSyncing) return |
| 44 | if (!sandbox) return console.error('Sandbox is not set up') |
| 45 | |
| 46 | await sandbox.destroy() |
| 47 | setSandbox(null) |
| 48 | setStatus('idle') |
| 49 | setError(undefined) |
| 50 | setStart(false) |
| 51 | } |
| 52 | |
| 53 | // Internal — takes the target explicitly so the boot path can pass the |
| 54 | // freshly booted core before React state has caught up. Callers outside |
| 55 | // the provider use `syncSandbox()` which sources the target from state. |
| 56 | const applyToCore = async (target: SandboxCore) => { |
| 57 | setIsSyncing(true) |
| 58 | |
| 59 | try { |
| 60 | const schemaDDL = await getDatabaseSchemaDDL({ |
| 61 | projectRef: project?.ref, |
| 62 | connectionString: project?.connectionString, |
| 63 | schemas: ['public'], |
| 64 | }) |
| 65 | |
| 66 | const seedData: TableSeedData[] = await getProjectSeedData({ |
| 67 | projectRef: project?.ref, |
| 68 | connectionString: project?.connectionString, |
| 69 | tables: [AUTH_USERS_SEED_TABLE, ...(schemaDDL.rlsStatuses ?? [])], |
| 70 | rowLimit: 100, |
| 71 | }) |
| 72 | |
| 73 | await target.setSchema(schemaDDL) |
| 74 | await target.setSeed(seedData) |
| 75 | } catch (e) { |
| 76 | const message = getErrorMessage(e) ?? String(e) |
| 77 | if (sandbox) { |
| 78 | // Refresh path — sandbox is still usable with the previous schema/data. |
| 79 | toast.error(`Failed to refresh sandbox: ${message}`) |
| 80 | } else { |
| 81 | // Boot path — propagate so the outer .catch sets status='error' and |
| 82 | // the SandboxManagement error branch renders. |
| 83 | throw e |
| 84 | } |
| 85 | } finally { |
| 86 | setIsSyncing(false) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | const syncSandbox = async () => { |
| 91 | if (isSyncing) return |
| 92 | if (!sandbox) return console.error('Sandbox has not been loaded') |
| 93 | await applyToCore(sandbox) |
| 94 | } |
| 95 | |
| 96 | useEffect(() => { |
| 97 | if (!start) return |
| 98 | |
| 99 | let cancelled = false |
| 100 | setStatus('loading') |
| 101 | |
| 102 | getSandboxCore() |
| 103 | .then(async (core) => { |
| 104 | if (cancelled) return |
| 105 | |
| 106 | await applyToCore(core) |
| 107 | setSandbox(core) |
| 108 | setStatus('ready') |
| 109 | }) |
| 110 | .catch((error) => { |
| 111 | if (cancelled) return |
| 112 | |
| 113 | setError(getErrorMessage(error) ?? '') |
| 114 | setStatus('error') |
| 115 | setStart(false) |
| 116 | }) |
| 117 | |
| 118 | return () => { |
| 119 | cancelled = true |
| 120 | } |
| 121 | // applyToCore intentionally omitted: this effect should fire once when |
| 122 | // `start` flips, not every time the helper identity changes. |
| 123 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 124 | }, [start]) |
| 125 | |
| 126 | return ( |
| 127 | <SandboxContext.Provider |
| 128 | value={{ |
| 129 | status, |
| 130 | error, |
| 131 | sandbox, |
| 132 | isSyncing, |
| 133 | startSandbox: () => setStart(true), |
| 134 | destroySandbox, |
| 135 | syncSandbox, |
| 136 | }} |
| 137 | > |
| 138 | {children} |
| 139 | </SandboxContext.Provider> |
| 140 | ) |
| 141 | } |
| 142 | |
| 143 | export const usePostgresSandbox = () => useContext(SandboxContext) |