sandbox.tsx143 lines · main
1import { noop } from 'lodash'
2import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
3import { toast } from 'sonner'
4
5import { AUTH_USERS_SEED_TABLE } from './sandbox.constants'
6import { getSandboxCore, type SandboxCore } from './sandbox.core'
7import { getDatabaseSchemaDDL } from '@/data/rls-tester/get-schema-ddl'
8import { getProjectSeedData, TableSeedData } from '@/data/rls-tester/get-seed-data'
9import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
10import { getErrorMessage } from '@/lib/get-error-message'
11
12type SandboxStatus = 'idle' | 'loading' | 'ready' | 'error'
13
14const 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
32export 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
143export const usePostgresSandbox = () => useContext(SandboxContext)